]*class=['"][^'"]*erasure-text-inline[^'"]*['"][^>]*>.{0,2000}?<\/div>/gis;
+
+/**
+ * Split one field's htmlData into the separate registered entities it holds.
+ *
+ * @param {string} html
+ * @param {{strict?:boolean}} [opts] `strict` turns the erased-with-content contradiction into a throw.
+ * @returns {{text:string, erased:boolean}[]}
+ */
+export function entityBlocks(html, { strict = false } = {}) {
+ if (html == null || String(html).trim() === '') return [];
+ const decoded = decodeEntities(html); // decode BEFORE splitting — see the order above
+ const chunks = decoded
+ .split(/
]*class=['"][^'"]*record-container)/i))
+ .map((c) => c.trim())
+ .filter((c) => c !== '');
+
+ const out = [];
+ for (const chunk of chunks) {
+ const erased = ERASED_MARKER.test(chunk);
+ // Read the visible text WITHOUT the erasure notice, so „Заличено обстоятелство." never counts as
+ // content and an erased block reads as empty.
+ const withoutNotice = chunk.replace(ERASURE_NOTICE, ' ');
+ const text = stripTags(withoutNotice);
+ if (erased && text !== '' && strict) {
+ throw new Error(
+ `REFUSE: an erased entity carries content (${JSON.stringify(text.slice(0, 60))}) — the ` +
+ `"erased ⇒ empty" assumption has drifted; stop rather than guess which state is in force`,
+ );
+ }
+ if (text === '' && !erased) continue; // structural noise, not an entity
+ out.push({ text, erased });
+ }
+ return out;
+}
+
+/** Every field in the deed, flattened out of sections → subDeeds → groups. */
+function allFields(deed) {
+ const out = [];
+ for (const s of deed?.sections ?? [])
+ for (const sd of s?.subDeeds ?? [])
+ for (const g of sd?.groups ?? []) for (const f of g?.fields ?? []) out.push(f);
+ return out;
+}
+
+const isoDay = (v) => (v ? String(v).slice(0, 10) : null);
+
+/**
+ * The LIVE entities of the requested field codes — the single entry point to live state.
+ * Erased entities are dropped here and nowhere else, so the rule is auditable in one place.
+ * @returns {{nameCode:string, entryDate:string|null, entryNumber:string|null, entities:string[]}[]}
+ */
+export function liveFields(deed, nameCodes, opts = {}) {
+ const want = new Set(nameCodes);
+ const out = [];
+ for (const f of allFields(deed)) {
+ if (!want.has(f.nameCode)) continue;
+ const entities = entityBlocks(f.htmlData, opts)
+ .filter((b) => !b.erased)
+ .map((b) => b.text);
+ if (entities.length === 0) continue;
+ out.push({
+ nameCode: f.nameCode,
+ // TEXT, never a number: a fieldEntryNumber like 20130716101007 exceeds 2^53 once combined.
+ entryNumber: f.fieldEntryNumber == null ? null : String(f.fieldEntryNumber),
+ entryDate: isoDay(f.fieldEntryDate),
+ entities,
+ });
+ }
+ return out;
+}
+
+// ── names ─────────────────────────────────────────────────────────────────────
+/**
+ * Name tokens: NFC, upper case, split on non-letters, keep tokens of length ≥2.
+ *
+ * Dropping 1-character tokens is what makes „Г. И. Петров" a ONE-token name rather than a three-token
+ * one — an abbreviated name can then never reach the ≥3 tokens rung 2 requires, instead of passing on
+ * initials that match half the register. Latin letters are kept (never folded onto Cyrillic
+ * look-alikes) so a homoglyph is a non-match rather than a false match, matching companyNameKey's
+ * posture in packages/shared/src/company-name-key.ts.
+ *
+ * A near-twin of the module-private `holderTokens` in scripts/cacbg/parse.mjs — deliberately
+ * re-implemented rather than imported, because that module pulls in fast-xml-parser and would tie
+ * these pure tests to a workspace install. Keep the two in step.
+ */
+export function personTokens(name) {
+ return String(name ?? '')
+ .normalize('NFC')
+ .toUpperCase()
+ .split(/[^\p{L}]+/u)
+ .filter((t) => [...t].length >= 2);
+}
+
+/**
+ * Does EVERY token of the declarant's name appear as a whole token of this ONE entity?
+ *
+ * Full subset, not a majority: of 301 measured matches, 46 were two-token only, which is precisely
+ * the homonym risk. Whole-token, not substring: „ПЕТРОВ" inside „ПЕТРОВА" is a different person.
+ *
+ * Callers MUST pass a single entity's text (see entityBlocks). Passing a whole field is the
+ * cross-entity bug, and no signature can prevent it — the test does.
+ */
+export function fullSubsetMatch(declarantName, entityText) {
+ const want = personTokens(declarantName);
+ if (want.length === 0) return false;
+ const have = new Set(personTokens(entityText));
+ return want.every((t) => have.has(t));
+}
+
+// ── seat ──────────────────────────────────────────────────────────────────────
+// Strip a settlement-type prefix only as a WHOLE token: „гр."/„с."/„общ."/„обл."/„ж.к." followed by a
+// dot and optional space. R9 — a loose prefix strip turns СОФИЯ into ОФИЯ and ГРАДЕЦ into АДЕЦ.
+const SETTLEMENT_PREFIX = /^(?:ГР|С|ОБЩ|ОБЛ|Ж\.К)\.\s*/u;
+
+/** Normalise a settlement name for comparison. Empty in ⇒ empty out, and empty NEVER confirms. */
+export function normalizeSettlement(raw) {
+ let s = String(raw ?? '')
+ .normalize('NFC')
+ .toUpperCase()
+ .replace(/\([^)]*\)/g, ' ') // „(столица)"
+ .trim();
+ s = s.split(/[,/]/)[0].trim(); // cut at the first comma or slash — „с. Марково, п.к. 4108"
+ s = s.replace(SETTLEMENT_PREFIX, '');
+ return s
+ .replace(/[^\p{L}\s-]/gu, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/**
+ * The company's registered settlement, from CR_F_5_L's „Населено място:" segment ONLY.
+ *
+ * ADR-0010 item 3 (addresses are never extracted) is honoured here, and the field makes that a live
+ * concern rather than a formality: CR_F_5_L also carries street, postcode, phone, fax, e-mail and
+ * website. Nothing but the settlement and the entry date leaves this function.
+ * @returns {{settlement:string, entryDate:string|null}}
+ */
+export function registrySeat(deed) {
+ for (const f of liveFields(deed, ['CR_F_5_L'])) {
+ for (const text of f.entities) {
+ const m = text.match(/Населено място:\s*([^,]+)/u);
+ if (m) return { settlement: normalizeSettlement(m[1]), entryDate: f.entryDate };
+ }
+ }
+ return { settlement: '', entryDate: null };
+}
+
+// ── legal form ────────────────────────────────────────────────────────────────
+// Codes observed empirically (#279 §3 + the spike's catalogue read). DELIBERATELY incomplete: the
+// nomenclature endpoint does not settle the enum (legalForm=4 and =10 return a byte-identical
+// catalogue), so anything absent here is `unknown` and WITHHOLDS. In particular whether ЕАД carries
+// its own code is unresolved — ООД and ЕООД turned out NOT to share one (4 vs 10), so assuming ЕАД
+// shares 5 with АД would be exactly the fail-open this bar exists to prevent.
+const FORM_CODES = new Map([
+ [1, 'closely_held'], // ЕТ
+ [4, 'closely_held'], // ООД
+ [5, 'joint_stock'], // АД
+ [6, 'joint_stock'], // КДА
+ [10, 'closely_held'], // ЕООД
+]);
+
+// The фирма's legal form is its SUFFIX under ЗТРРЮЛНЦ, and the deed envelope's `fullName` carries it
+// („ПИМК" ООД) while CR_F_2_L is bare („ПИМК"). So the bar has a second, independent signal at zero
+// extra cost.
+//
+// DELIBERATELY a twin of classify.mjs's JOINT_STOCK rather than an import, for the same reason
+// personTokens twins parse.mjs's holderTokens: the dependency direction here is cacbg → tr (load.mjs
+// imports this module), so importing back out of scripts/cacbg/ would close a cycle across the two
+// directories. The two are pinned byte-identical by a test in deed.test.mjs — this comment used to
+// claim КДА was missing from classify.mjs, which stopped being true in 5f64f5c, and an unenforced
+// „keep these in step" note is exactly how that happens.
+export const JOINT_SUFFIX = /(?:^|[\s"„“”«»])(АД|ЕАД|АДСИЦ|КДА)[\s"„“”«»]*$/u;
+const CLOSELY_SUFFIX = /(?:^|[\s"„“”«»])(ООД|ЕООД|ЕТ|ДЗЗД|КД|СД|КООПЕРАЦИЯ)[\s"„“”«»]*$/u;
+
+/**
+ * Legal-form verdict — a union of the numeric code and the mandated фирма suffix.
+ * Either signal saying joint-stock bars the link. Neither able to say ⇒ `unknown`, which withholds.
+ * @returns {{code:number|null, codeVerdict:string, suffixVerdict:string, verdict:string}}
+ */
+export function registryLegalForm(deed) {
+ const code = typeof deed?.legalForm === 'number' ? deed.legalForm : null;
+ const codeVerdict = (code != null && FORM_CODES.get(code)) || 'unknown';
+
+ const name = String(deed?.fullName ?? '')
+ .normalize('NFC')
+ .toUpperCase()
+ .trim();
+ const suffixVerdict = JOINT_SUFFIX.test(name)
+ ? 'joint_stock'
+ : CLOSELY_SUFFIX.test(name)
+ ? 'closely_held'
+ : 'unknown';
+
+ const verdict =
+ codeVerdict === 'joint_stock' || suffixVerdict === 'joint_stock'
+ ? 'joint_stock'
+ : codeVerdict === 'closely_held' || suffixVerdict === 'closely_held'
+ ? 'closely_held'
+ : 'unknown';
+ return { code, codeVerdict, suffixVerdict, verdict };
+}
+
+// ── refutation input ──────────────────────────────────────────────────────────
+/**
+ * Latest entry date across the LIVE ownership fields, or null when none survives.
+ *
+ * The trap this avoids was present in the first company sampled: CR_F_23_L sits in the CURRENT deed
+ * dated 2013-07-16 carrying only „Заличено обстоятелство.". Counted naively it becomes the latest
+ * ownership entry and can refute a link it says nothing about. liveFields drops it.
+ */
+export function latestOwnershipEntryDate(deed) {
+ const dates = liveFields(deed, OWNERSHIP_FIELDS)
+ .map((f) => f.entryDate)
+ .filter(Boolean);
+ return dates.length ? dates.sort().at(-1) : null;
+}
+
+/**
+ * The deed we got back must be the deed we asked for.
+ *
+ * R8: Bulgarian public bodies carry ЕИК of exactly the `000…` shape, so any numeric round-trip on the
+ * path silently rewrites the identifier. Without this rail, that failure publishes a claim about a
+ * different company under the right official's name.
+ */
+export function assertUicEcho(deed, requestedEik) {
+ const got = deed?.uic == null ? null : String(deed.uic);
+ if (got !== String(requestedEik)) {
+ throw new Error(
+ `REFUSE: deed uic echo mismatch — requested ${JSON.stringify(String(requestedEik))}, ` +
+ `deed reports ${JSON.stringify(got)}`,
+ );
+ }
+ return deed;
+}
diff --git a/scripts/tr/deed.test.mjs b/scripts/tr/deed.test.mjs
new file mode 100644
index 000000000..9c539c874
--- /dev/null
+++ b/scripts/tr/deed.test.mjs
@@ -0,0 +1,391 @@
+// node:test — the deed parser. Pure, offline, and the most dangerous code in this change.
+//
+// Every fixture below is REAL markup, copied verbatim from a live deed (ЕИК 115536179, fetched
+// 2026-08-05) with person names replaced only where a test needs a specific shape. Writing this
+// parser against imagined markup is how the entity-boundary bug ships.
+//
+// The failure this file exists to prevent: field CR_F_19_L holds THREE separate people in one string,
+// separated by
. Matching a declarant's tokens against the whole field lets
+// the given name of one person combine with the surname of another, and the result is a named public
+// claim that a specific official owns a specific company — about the wrong person. ADR-0033 decision 2.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ entityBlocks,
+ liveFields,
+ personTokens,
+ fullSubsetMatch,
+ normalizeSettlement,
+ registrySeat,
+ registryLegalForm,
+ latestOwnershipEntryDate,
+ assertUicEcho,
+ JOINT_SUFFIX,
+} from './deed.mjs';
+
+// ── real markup ───────────────────────────────────────────────────────────────
+const F19_THREE = `
ПИМК ХОЛДИНГ ГРУП АД, ЕИК/ПИК 202294392, Държава: БЪЛГАРИЯ, Размер на дяловото участие: 59980.00 лв.
ПЕНКО НЕСТОРОВ НЕСТОРОВ, Държава: БЪЛГАРИЯ, Размер на дяловото участие: 10.00 лв.
ИЛИЯН КОСТАДИНОВ ФИЛИПОВ, Държава: БЪЛГАРИЯ, Размер на дяловото участие: 10.00 лв.
`;
+
+const F23_ERASED = `
`;
+
+const F5_SEAT = `
Държава: БЪЛГАРИЯ
Област: Пловдив, Община: Родопи
Населено място: с. Марково, п.к. 4108
бул./ул. местност ЗАХАРИДЕВО № 043А Телефон: 032/901102 и 032/945149
Адрес на електронна поща: office@pimk-bg.eu
`;
+
+const F7_MANAGER = `
АНТОН ИЦКОВ ЙОРДАНОВ, Държава: БЪЛГАРИЯ
`;
+
+const field = (nameCode, htmlData, over = {}) => ({
+ nameCode,
+ htmlData,
+ fieldEntryNumber: '20130716101007',
+ fieldEntryDate: '2013-07-16T10:10:07',
+ fieldOperation: 3,
+ fieldIdent: '00190',
+ ...over,
+});
+const deedOf = (fields, over = {}) => ({
+ uic: '115536179',
+ fullName: '"ПИМК" ООД',
+ legalForm: 4,
+ sections: [{ subDeeds: [{ groups: [{ fields }] }] }],
+ ...over,
+});
+
+// ── T1 — the entity boundary ──────────────────────────────────────────────────
+test('entityBlocks splits one field into its separate registered entities', () => {
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(blocks.length, 3, 'three съдружници, three blocks');
+ assert.match(blocks[0].text, /ПИМК ХОЛДИНГ ГРУП АД/);
+ assert.match(blocks[1].text, /ПЕНКО НЕСТОРОВ НЕСТОРОВ/);
+ assert.match(blocks[2].text, /ИЛИЯН КОСТАДИНОВ ФИЛИПОВ/);
+ // No block may carry another block's name — that is the whole point.
+ assert.ok(!blocks[1].text.includes('ФИЛИПОВ'));
+ assert.ok(!blocks[2].text.includes('ПЕНКО'));
+});
+
+test('T1 — tokens from two DIFFERENT people never combine into a match', () => {
+ // „ПЕНКО … НЕСТОРОВ" and „ИЛИЯН КОСТАДИНОВ ФИЛИПОВ" are both in field 19. A declarant assembled
+ // from one person's given name and another's patronymic+surname must NOT match.
+ const frankenstein = 'ПЕНКО КОСТАДИНОВ ФИЛИПОВ';
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch(frankenstein, b.text)),
+ false,
+ 'cross-entity match — this is the libel bug',
+ );
+ // Whole-field matching is exactly what must not be done; prove the naive approach WOULD have fired,
+ // so this test cannot silently pass because the matcher is broken in some other way.
+ assert.equal(fullSubsetMatch(frankenstein, F19_THREE), true, 'naive whole-field match fires');
+});
+
+// The live register emits single-quoted attributes today, and every fixture above is verbatim from a
+// real deed. But the entity split is the ONLY thing standing between three people and one merged
+// token pool, and it must not be quiet about a markup change: if the register ever switches to
+// class="…", a quote-specific pattern stops splitting, all three owners collapse into one block, and
+// the frankenstein match above starts firing — a named public claim about a person who is not there.
+// R7's doctrine for this module is „refuse loudly, never guess"; silently mis-splitting is neither.
+const dq = (s) => s.replace(/class='([^']*)'/g, 'class="$1"');
+
+test('T1 — the entity split survives double-quoted attributes (markup-drift hardening)', () => {
+ // Strip the
separators first. entityBlocks splits on BOTH
and record-container precisely
+ // because either may be absent; with the
present this test would pass on the
rule alone
+ // and prove nothing about the quote handling it is here to pin.
+ const blocks = entityBlocks(dq(F19_THREE).replace(/
]*>/gi, ''));
+ assert.equal(blocks.length, 3, 'a quote style change must not merge three owners into one block');
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch('ПЕНКО КОСТАДИНОВ ФИЛИПОВ', b.text)),
+ false,
+ 'cross-entity match under double quotes — the libel bug via markup drift',
+ );
+ assert.ok(blocks.some((b) => fullSubsetMatch('ПЕНКО НЕСТОРОВ НЕСТОРОВ', b.text)));
+});
+
+test('T1 — erasure is still detected and stripped under double-quoted attributes', () => {
+ const [block] = entityBlocks(dq(F23_ERASED));
+ assert.equal(block.erased, true);
+ assert.equal(block.text, '', 'the erasure notice must not survive as content');
+ // The strict contradiction check must not fire merely because the quote style changed.
+ assert.doesNotThrow(() => entityBlocks(dq(F23_ERASED), { strict: true }));
+});
+
+test('T1 positive control — the CORRECT declarant does match', () => {
+ // Without this, a matcher that always returns false passes every negative test above (ADR-0027).
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch('ИЛИЯН КОСТАДИНОВ ФИЛИПОВ', b.text)),
+ true,
+ );
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch('Пенко Несторов Несторов', b.text)),
+ true,
+ 'case/spacing drift still matches within the right entity',
+ );
+});
+
+test('T1 — HTML entities are decoded BEFORE splitting, not after', () => {
+ // Decode-after-split leaves "-bearing names mangled; decode-before-split is the tested order.
+ const encoded = F19_THREE.replace('ПИМК ХОЛДИНГ ГРУП АД', '"ПИМК ХОЛДИНГ ГРУП" АД');
+ const blocks = entityBlocks(encoded);
+ assert.equal(blocks.length, 3);
+ assert.match(blocks[0].text, /"ПИМК ХОЛДИНГ ГРУП" АД/);
+ assert.ok(!blocks[0].text.includes('"'), 'entities must be decoded');
+});
+
+test('T1 — a corporate съдружник contributes its own tokens, not a person match', () => {
+ // „ПИМК ХОЛДИНГ ГРУП АД, ЕИК/ПИК 202294392" is a legal entity. A person whose name happens to
+ // overlap its words must not match it into existence.
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(
+ fullSubsetMatch('ПИМК ХОЛДИНГ ГРУП', blocks[0].text),
+ true,
+ 'literal overlap exists',
+ );
+ // …which is why rung 2 requires ≥3 tokens of a PERSON name; the guard lives in evidence.mjs (T2).
+});
+
+// ── erasure ───────────────────────────────────────────────────────────────────
+test('an erased entity is detected structurally and dropped from live state', () => {
+ // MEASURED: the live deed marks erasure with `erasure-text-inline` and the container carries NO
+ //
at all. An earlier note claimed the marker was `field-text--erased`; that
+ // class does not occur in the sampled deed. Both are treated as erasure so either shape is safe.
+ const blocks = entityBlocks(F23_ERASED);
+ assert.equal(blocks.length, 1);
+ assert.equal(blocks[0].erased, true);
+ const live = liveFields(deedOf([field('CR_F_23_L', F23_ERASED, { fieldOperation: 2 })]), [
+ 'CR_F_23_L',
+ ]);
+ assert.deepEqual(live, [], 'an erased field contributes no live entity');
+});
+
+test('the OTHER erasure spelling is honoured too', () => {
+ const alt = `
СТАР СОБСТВЕНИК
Заличено обстоятелство.
`;
+ assert.equal(entityBlocks(alt)[0].erased, true);
+});
+
+test('an erased-looking block carrying real content REFUSES the deed (drift alarm)', () => {
+ // R7: "erased ⇒ empty" is an empirical observation (93 of 93). If the register ever emits an erased
+ // marker beside live content, the assumption is broken and we must stop, not silently drop a real
+ // owner or silently keep a removed one.
+ const contradictory = `
ЖИВО ИМЕ ТУК
Заличено обстоятелство.
`;
+ assert.throws(() => entityBlocks(contradictory, { strict: true }), /erased.*content|drift/i);
+});
+
+test('an empty htmlData yields no entities and does not throw', () => {
+ assert.deepEqual(entityBlocks(''), []);
+ assert.deepEqual(entityBlocks(null), []);
+});
+
+// A numeric entity is attacker-shaped input in the only sense that matters here: it comes off the
+// wire, and `String.fromCodePoint` throws RangeError above U+10FFFF. That throw escapes decodeEntities,
+// escapes entityBlocks and registrySeat, and — because the crawl loop's try/catch covers only
+// JSON.parse + assertUicEcho — escapes run() and kills the process. One malformed entity in one deed
+// would end a paced crawl that has already spent its request budget. Out of range is not a person, so
+// the only defensible reading is „no character": drop it and keep parsing the rest of the entity.
+test('an out-of-range numeric entity is dropped, never thrown out of the parser', () => {
+ const overflow = F19_THREE.replace('ПЕНКО', 'ПЕНКО');
+ const blocks = entityBlocks(overflow);
+ assert.equal(blocks.length, 3, 'the deed still parses into its three entities');
+ assert.match(blocks[1].text, /ПЕНКО НЕСТОРОВ НЕСТОРОВ/, 'the surrounding name survives intact');
+ assert.ok(!blocks[1].text.includes(''), 'the escape itself does not survive as literal text');
+});
+
+test('the hex numeric form is guarded too — both decode lines, not just the decimal one', () => {
+ const overflow = F19_THREE.replace('ИЛИЯН', 'ИЛИЯН');
+ const blocks = entityBlocks(overflow);
+ assert.equal(blocks.length, 3);
+ assert.match(blocks[2].text, /ИЛИЯН КОСТАДИНОВ ФИЛИПОВ/);
+});
+
+test('an in-range numeric entity still decodes — the guard bounds, it does not disable', () => {
+ // П is „П". A guard that dropped every numeric escape would silently mangle real names.
+ const blocks = entityBlocks(F19_THREE.replace('ПЕНКО', 'ПЕНКО'));
+ assert.match(blocks[1].text, /ПЕНКО НЕСТОРОВ НЕСТОРОВ/);
+});
+
+test('registrySeat survives the same malformed entity rather than aborting the load', () => {
+ // registrySeat sits OUTSIDE the crawl loop's refuse-and-continue block (fetch-deeds.mjs), and
+ // load.mjs calls it again at decision time — so an unguarded throw here takes down both legs.
+ const d = deedOf([field('CR_F_5_L', F5_SEAT.replace('с. Марково', 'с. Марково'))]);
+ const seat = registrySeat(d);
+ assert.equal(seat.settlement, 'МАРКОВО');
+});
+
+test('liveFields keeps only the requested codes and reports entry date/number', () => {
+ const d = deedOf([
+ field('CR_F_7_L', F7_MANAGER, { nameCode: 'CR_F_7_L', fieldEntryDate: '2017-09-15T00:00:00' }),
+ field('CR_F_19_L', F19_THREE),
+ field('CR_F_5_L', F5_SEAT),
+ ]);
+ const live = liveFields(d, ['CR_F_7_L', 'CR_F_19_L']);
+ assert.deepEqual(live.map((f) => f.nameCode).sort(), ['CR_F_19_L', 'CR_F_7_L']);
+ const f19 = live.find((f) => f.nameCode === 'CR_F_19_L');
+ assert.equal(f19.entities.length, 3);
+ assert.equal(f19.entryDate, '2013-07-16');
+ assert.equal(f19.entryNumber, '20130716101007');
+ assert.equal(typeof f19.entryNumber, 'string', 'entry numbers exceed 2^53 — never a number');
+});
+
+// ── T2 — the token rule ───────────────────────────────────────────────────────
+test('personTokens keeps tokens of length ≥2 and folds case/spacing', () => {
+ assert.deepEqual(personTokens('Иван Петров Георгиев'), ['ИВАН', 'ПЕТРОВ', 'ГЕОРГИЕВ']);
+ assert.deepEqual(personTokens(' иван петров '), ['ИВАН', 'ПЕТРОВ']);
+ // An initial is not a token — „Г. И. Петров" is ONE token, so it can never reach three.
+ assert.deepEqual(personTokens('Г. И. Петров'), ['ПЕТРОВ']);
+ // A hyphenated surname is one token, not two.
+ assert.deepEqual(personTokens('Мария Иванова-Петрова'), ['МАРИЯ', 'ИВАНОВА', 'ПЕТРОВА']);
+});
+
+test('fullSubsetMatch requires EVERY declarant token, not a majority', () => {
+ const entity = 'ИЛИЯН КОСТАДИНОВ ФИЛИПОВ, Държава: БЪЛГАРИЯ';
+ assert.equal(fullSubsetMatch('ИЛИЯН КОСТАДИНОВ ФИЛИПОВ', entity), true);
+ // 2-of-3 must fail: of 301 measured matches, 46 were two-token only — the homonym risk itself.
+ assert.equal(fullSubsetMatch('ИЛИЯН КОСТАДИНОВ ПЕТРОВ', entity), false);
+ assert.equal(fullSubsetMatch('ИЛИЯН ПЕТРОВ ФИЛИПОВ', entity), false);
+});
+
+test('a token must match a WHOLE token, never a substring', () => {
+ // „ПЕТРОВ" must not be found inside „ПЕТРОВА"; that is a different person.
+ assert.equal(fullSubsetMatch('ИВАН ПЕТРОВ ГЕОРГИЕВ', 'ИВАН ПЕТРОВА ГЕОРГИЕВА'), false);
+});
+
+// ── T5 — settlement normalization ─────────────────────────────────────────────
+test('T5 — the settlement prefix is stripped only as a whole token', () => {
+ // R9: a naive prefix strip turns СОФИЯ into ОФИЯ and ГРАДЕЦ into АДЕЦ.
+ assert.equal(normalizeSettlement('гр. Русе'), 'РУСЕ');
+ assert.equal(normalizeSettlement('с. Марково'), 'МАРКОВО');
+ assert.equal(normalizeSettlement('София'), 'СОФИЯ');
+ assert.equal(normalizeSettlement('СОФИЯ'), 'СОФИЯ');
+ assert.equal(normalizeSettlement('Градец'), 'ГРАДЕЦ');
+ assert.equal(normalizeSettlement('гр.Пловдив'), 'ПЛОВДИВ');
+ assert.equal(normalizeSettlement('София (столица)'), 'СОФИЯ');
+});
+
+test('T5 — an empty settlement never equals another empty settlement', () => {
+ // „both blank ⇒ confirmed" would rubber-stamp every link with no seat data at all.
+ assert.equal(normalizeSettlement(''), '');
+ assert.equal(normalizeSettlement(null), '');
+ assert.equal(normalizeSettlement(' '), '');
+});
+
+test('registrySeat reads the „Населено място" segment and NOTHING else', () => {
+ const seat = registrySeat(deedOf([field('CR_F_5_L', F5_SEAT, { nameCode: 'CR_F_5_L' })]));
+ assert.equal(seat.settlement, 'МАРКОВО');
+ assert.equal(seat.entryDate, '2013-07-16');
+ // ADR-0010 item 3: the parser never returns an address, phone, e-mail or website — and the deed
+ // demonstrably carries all four.
+ const blob = JSON.stringify(seat);
+ for (const leak of ['ЗАХАРИДЕВО', '032/901102', 'office@pimk-bg.eu', 'п.к.', '4108', 'Родопи'])
+ assert.ok(!blob.includes(leak), `seat must not carry ${leak}`);
+});
+
+// ── T3 — the joint-stock bar ──────────────────────────────────────────────────
+test('T3 — the legal-form verdict is a UNION of the code and the ЗТРРЮЛНЦ suffix', () => {
+ const jointByCode = registryLegalForm(deedOf([], { legalForm: 5, fullName: 'НЕЩО СИ' }));
+ assert.equal(jointByCode.verdict, 'joint_stock');
+
+ // An UNKNOWN code must not fall through: the suffix decides, and if it cannot, we withhold.
+ const unknownButEad = registryLegalForm(deedOf([], { legalForm: 99, fullName: '"ГАМА" ЕАД' }));
+ assert.equal(unknownButEad.verdict, 'joint_stock', 'barred by the mandated suffix');
+
+ const unknownButEood = registryLegalForm(deedOf([], { legalForm: 99, fullName: '"БЕТА" ЕООД' }));
+ assert.equal(unknownButEood.verdict, 'closely_held', 'the bar is not blanket');
+
+ const unknownAndUnreadable = registryLegalForm(deedOf([], { legalForm: 99, fullName: 'НЕЩО' }));
+ assert.equal(unknownAndUnreadable.verdict, 'unknown', 'unknown withholds — it never publishes');
+});
+
+test('T3 — КДА is barred (it is not in the existing closelyHeldForm token list)', () => {
+ assert.equal(
+ registryLegalForm(deedOf([], { legalForm: 99, fullName: '"X" КДА' })).verdict,
+ 'joint_stock',
+ );
+ assert.equal(
+ registryLegalForm(deedOf([], { legalForm: 6, fullName: 'X' })).verdict,
+ 'joint_stock',
+ );
+});
+
+test('T3 — a real ООД deed reads as closely held, by code AND by suffix', () => {
+ const v = registryLegalForm(deedOf([]));
+ assert.equal(v.code, 4);
+ assert.equal(v.verdict, 'closely_held');
+ assert.equal(v.suffixVerdict, 'closely_held', 'fullName carries the form: "ПИМК" ООД');
+});
+
+// ── T7 — the UIC echo ─────────────────────────────────────────────────────────
+test('T7 — a deed whose UIC does not echo the request is REFUSED', () => {
+ // R8: ЕИК leading zeros are significant (public bodies are exactly 000…). If anything on the path
+ // rewrites the identifier, this is the rail that catches it before a claim is made about the
+ // wrong company.
+ assert.doesNotThrow(() => assertUicEcho(deedOf([]), '115536179'));
+ assert.throws(() => assertUicEcho(deedOf([]), '000696327'), /uic|echo/i);
+ assert.throws(() => assertUicEcho(deedOf([], { uic: '696327' }), '000696327'), /uic|echo/i);
+ assert.throws(() => assertUicEcho(deedOf([], { uic: null }), '115536179'), /uic|echo/i);
+});
+
+// ── the refutation input ──────────────────────────────────────────────────────
+test('latestOwnershipEntryDate ignores ERASED ownership fields', () => {
+ // The trap, present in the first company sampled: CR_F_23_L is live in the current deed, dated
+ // 2013-07-16, and contains only „Заличено обстоятелство.". Read naively it becomes „latest
+ // ownership entry: 2013-07-16" and can refute a link it knows nothing about.
+ const d = deedOf([
+ field('CR_F_19_L', F19_THREE, { fieldEntryDate: '2011-05-02T00:00:00' }),
+ field('CR_F_23_L', F23_ERASED, { fieldEntryDate: '2013-07-16T10:10:07', fieldOperation: 2 }),
+ ]);
+ assert.equal(latestOwnershipEntryDate(d), '2011-05-02', 'the erased 2013 entry must not count');
+});
+
+test('latestOwnershipEntryDate is null when no live ownership field survives', () => {
+ const d = deedOf([field('CR_F_23_L', F23_ERASED, { fieldOperation: 2 })]);
+ assert.equal(latestOwnershipEntryDate(d), null);
+});
+
+// The erasure-notice strip used an unbounded lazy `.*?`, which backtracks quadratically when the opening
+// div is never closed — each opening restarts a scan to end-of-input. Measured on that shape: 34K→3.3ms,
+// 68K→13.6ms, 136K→53.8ms, 272K→240ms, 1M→4.0s (×4 per doubling). The parser runs against whatever the
+// register returns, so that is remote-controlled CPU on a paced crawl with a per-request budget.
+test('adversarial unclosed markup parses in linear time, not quadratically', () => {
+ // ~1 MB of unclosed erasure openings — the exact shape that triggers the backtracking.
+ const doc = '
z'.repeat(32_000);
+ const t = process.hrtime.bigint();
+ entityBlocks(doc);
+ const ms = Number(process.hrtime.bigint() - t) / 1e6;
+ // Bounded measures ~190ms here and unbounded ~4000ms, so 1500ms separates them with ~8× headroom
+ // over the bounded path — wide enough not to flake on a loaded runner, tight enough to catch a
+ // reintroduced `.*?`.
+ assert.ok(ms < 1500, `entityBlocks took ${ms.toFixed(0)}ms on 1MB of unclosed markup`);
+});
+
+test('an over-long erasure notice still marks the block erased — the bound cannot leak a live owner', () => {
+ // If the notice exceeds the bound the regex simply does not strip it. `erased` is decided separately
+ // by ERASED_MARKER, so the block is still erased and liveFields still drops it: the failure mode of
+ // the bound is a noisier block, never a resurrected owner.
+ const long = `
${'Заличено. '.repeat(400)}
`;
+ const [block] = entityBlocks(long);
+ assert.equal(block.erased, true);
+ assert.deepEqual(
+ liveFields(deedOf([field('CR_F_19_L', long)]), ['CR_F_19_L']),
+ [],
+ 'an erased block contributes no live entity regardless of its notice length',
+ );
+});
+
+// JOINT_SUFFIX here and JOINT_STOCK in scripts/cacbg/classify.mjs are the SAME rule — which legal-form
+// suffixes mark a share-issuing company — held in two places because the TR parser cannot import out of
+// scripts/cacbg/ without closing a cacbg↔tr cycle. The drift this risks has already happened once: 5f64f5c
+// added КДА to classify.mjs while deed.mjs's comment still asserted it was absent there. A prose „keep
+// these in step" note does not keep anything in step; this does.
+test('the joint-stock suffix rule is identical in the TR parser and the classifier', async () => {
+ const { JOINT_STOCK } = await import('../cacbg/classify.mjs');
+ assert.equal(JOINT_SUFFIX.source, JOINT_STOCK.source, 'the two patterns have diverged');
+ assert.equal(JOINT_SUFFIX.flags, JOINT_STOCK.flags, 'the two patterns have diverged in flags');
+ // Behavioural pin as well as textual: identical sources with different behaviour is impossible, but a
+ // future refactor could legitimately change BOTH sources while breaking one. These are the forms the
+ // bar exists for — every one must be caught by both, or a joint-stock parcel publishes as ownership.
+ for (const name of ['ТРЕЙС ГРУП ХОЛД АД', 'НЕЩО ЕАД', 'ФОНД АДСИЦ', 'НЕЩО КДА']) {
+ assert.equal(JOINT_SUFFIX.test(name), true, name);
+ assert.equal(JOINT_STOCK.test(name), true, name);
+ }
+ for (const name of ['АЛФА СТРОЙ ООД', 'БЕТА ЕООД', 'АД ГРУП ООД']) {
+ assert.equal(JOINT_SUFFIX.test(name), false, name);
+ assert.equal(JOINT_STOCK.test(name), false, name);
+ }
+});
diff --git a/scripts/tr/eik.mjs b/scripts/tr/eik.mjs
new file mode 100644
index 000000000..5a4d5b125
--- /dev/null
+++ b/scripts/tr/eik.mjs
@@ -0,0 +1,61 @@
+// ЕИК validity — the Node twin of the rule that already lives in SQL.
+//
+// `eik_valid` in scripts/normalize-raw.sql decides which bidders get an ЕИК-keyed identity at all, so
+// it defines the ЕИК space the whole matcher works in. Until now that rule was callable ONLY from
+// SQL, which is why the registry leg needs this: the crawler must decide, in Node, whether a code is
+// worth a lookup. The two implementations are pinned against each other by a test that lifts the CASE
+// expression straight out of the .sql file and runs both over the same values (eik.test.mjs) — a copy
+// of the rule would drift from the thing it is copying.
+//
+// The rule (ЗТРРЮЛНЦ / БУЛСТАТ):
+// 9-digit — weight digits 1..8 by 1..8; control = sum % 11. If that is 10, re-weight by 3..10;
+// a second 10 becomes 0. Digit 9 must equal the control.
+// 13-digit — the leading 9 must themselves be a valid 9-digit ЕИК, then weight digits 9..12 by
+// 2,7,3,5 (fallback 4,9,5,7; a second 10 becomes 0). Digit 13 must equal that control.
+//
+// Everything here is string-in, string-out. An ЕИК is an identifier, not a number: public bodies carry
+// codes of exactly the `000…` shape, and a numeric round-trip drops the leading zeros and silently
+// turns one company's identifier into another's.
+
+/** Weighted control digit over `digits`, with the standard second-pass fallback. @returns {number} */
+function control(digits, primary, fallback) {
+ const sum = (ws) => ws.reduce((acc, w, i) => acc + w * digits[i], 0) % 11;
+ const first = sum(primary);
+ if (first < 10) return first;
+ const second = sum(fallback);
+ return second < 10 ? second : 0;
+}
+
+/**
+ * Is this a structurally valid ЕИК (9 or 13 digits, correct control digit)?
+ * Service codes (`000000000`, `0000000000000`) are rejected outright, matching the SQL: they pass the
+ * arithmetic but are placeholders, and letting them through collapsed unrelated foreign suppliers onto
+ * one node (#195).
+ * @param {unknown} eik @returns {boolean}
+ */
+export function eikChecksumValid(eik) {
+ const s = String(eik ?? '');
+ if (s === '000000000' || s === '0000000000000') return false;
+ if (!/^\d+$/.test(s)) return false;
+ if (s.length !== 9 && s.length !== 13) return false;
+
+ const d = [...s].map(Number);
+ const c9 = control(d.slice(0, 8), [1, 2, 3, 4, 5, 6, 7, 8], [3, 4, 5, 6, 7, 8, 9, 10]);
+ if (c9 !== d[8]) return false;
+ if (s.length === 9) return true;
+
+ const c13 = control(d.slice(8, 12), [2, 7, 3, 5], [4, 9, 5, 7]);
+ return c13 === d[12];
+}
+
+/**
+ * Normalise a raw ЕИК string to digits only, or null when it is not one.
+ * Mirrors the SQL's `eik_clean`: strips a leading „ЕИК " label and surrounding whitespace, and does
+ * NOT otherwise repair the value. Validity is a separate question — `eikChecksumValid`.
+ * @param {unknown} raw @returns {string|null}
+ */
+export function normalizeEik(raw) {
+ const s = String(raw ?? '').trim();
+ const stripped = (s.startsWith('ЕИК ') ? s.slice(4) : s).trim();
+ return /^\d{9}$|^\d{13}$/.test(stripped) ? stripped : null;
+}
diff --git a/scripts/tr/eik.test.mjs b/scripts/tr/eik.test.mjs
new file mode 100644
index 000000000..7a80a191e
--- /dev/null
+++ b/scripts/tr/eik.test.mjs
@@ -0,0 +1,135 @@
+// node:test — the ЕИК checksum, and its PARITY with the SQL that already owns this rule.
+//
+// Why parity matters: `eik_valid` in scripts/normalize-raw.sql decides which bidders get an ЕИК-keyed
+// identity at all, so it defines the ЕИК space the whole matcher works in. A JS twin that disagrees
+// would silently accept a code the pipeline rejects (or vice versa) and the registry lookup would be
+// made against an entity the rest of the system does not believe exists. The parity test below runs
+// both implementations over the same values through node:sqlite, so they cannot drift apart.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { DatabaseSync } from 'node:sqlite';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { eikChecksumValid, normalizeEik } from './eik.mjs';
+
+// Real ЕИК, verifiable against the public registers — the only external anchor this rule has.
+const REAL = [
+ '000696327', // Община София
+ '831661388', // Министерство на регионалното развитие
+ '115536179', // „ПИМК" ООД
+ '207695026', // „Профит Екстра" ЕООД
+];
+
+test('accepts real 9-digit ЕИК', () => {
+ for (const e of REAL) assert.equal(eikChecksumValid(e), true, e);
+});
+
+test('rejects a wrong-digit twin of every real ЕИК', () => {
+ // The failure this rule exists to stop (#195): a typo twin passing as a distinct real company and
+ // collapsing unrelated suppliers onto one node. Mutating the LAST digit must always break it.
+ for (const e of REAL) {
+ const bad = e.slice(0, 8) + ((Number(e[8]) + 1) % 10);
+ assert.equal(eikChecksumValid(bad), false, `${bad} (twin of ${e})`);
+ }
+});
+
+test('rejects service codes, wrong lengths and non-digits', () => {
+ for (const bad of [
+ '000000000',
+ '0000000000000',
+ '',
+ null,
+ undefined,
+ '12345678', // 8
+ '1234567890', // 10 — never a valid ЕИК length, which is what makes the ЕГН guard sound
+ '11553617x',
+ '115 536 179',
+ ]) {
+ assert.equal(eikChecksumValid(bad), false, String(bad));
+ }
+});
+
+test('13-digit branch requires BOTH the 9-digit prefix and the 13th control digit', () => {
+ // A клон/поделение code: the leading 9 must themselves be a valid ЕИК, then weights 2,7,3,5.
+ const base = '115536179';
+ const valid13 = thirteen(base, '001');
+ assert.equal(valid13.length, 13, valid13);
+ assert.equal(eikChecksumValid(valid13), true, valid13);
+ // break the 13th digit
+ const broken = valid13.slice(0, 12) + ((Number(valid13[12]) + 1) % 10);
+ assert.equal(eikChecksumValid(broken), false, broken);
+ // a valid 13th control over an INVALID 9-prefix must still fail — both halves are load-bearing
+ assert.equal(eikChecksumValid(thirteen('115536170', '001')), false);
+});
+
+// Build a 13-digit ЕИК with a correct final control digit: 9-digit prefix + 3 free digits + control.
+// The 2,7,3,5 weights run over positions 9..12 — that is the prefix's OWN control digit plus the three
+// free ones — and position 13 is the result.
+function thirteen(prefix9, three) {
+ const s = prefix9 + three;
+ const d = [...s].map(Number);
+ const w = (ws) => ws.reduce((a, x, i) => a + x * d[8 + i], 0) % 11;
+ let c = w([2, 7, 3, 5]);
+ if (c === 10) {
+ c = w([4, 9, 5, 7]);
+ if (c === 10) c = 0;
+ }
+ return s + c;
+}
+
+test('normalizeEik strips the „ЕИК " prefix and surrounding whitespace, like the SQL does', () => {
+ assert.equal(normalizeEik('ЕИК 115536179'), '115536179');
+ assert.equal(normalizeEik(' 115536179 '), '115536179');
+ assert.equal(normalizeEik('115536179'), '115536179');
+ assert.equal(normalizeEik('не е ЕИК'), null);
+ assert.equal(normalizeEik(null), null);
+});
+
+test('normalizeEik preserves leading zeros (public bodies are exactly 000…)', () => {
+ // Lose these and the crawler fetches a DIFFERENT company's deed. String in, string out — never a
+ // numeric round-trip.
+ assert.equal(normalizeEik('000696327'), '000696327');
+ assert.equal(typeof normalizeEik('000696327'), 'string');
+});
+
+// ── the anti-drift pin ────────────────────────────────────────────────────────
+test('JS twin agrees with normalize-raw.sql eik_valid on every value', () => {
+ const sql = readFileSync(fileURLToPath(new URL('../normalize-raw.sql', import.meta.url)), 'utf8');
+ const expr = extractEikValidExpression(sql);
+
+ const db = new DatabaseSync(':memory:');
+ db.exec('CREATE TABLE probe (eik_clean TEXT)');
+ const ins = db.prepare('INSERT INTO probe VALUES (?)');
+
+ const cases = [...REAL, ...REAL.map((e) => e.slice(0, 8) + ((Number(e[8]) + 1) % 10))];
+ for (const e of REAL) cases.push(thirteen(e, '001'), thirteen(e, '002'));
+ // Every 9-digit code over a fixed prefix — exercises BOTH weight passes incl. the second-10 → 0 fallback.
+ for (let i = 0; i < 100; i++) cases.push('2011223' + String(i).padStart(2, '0'));
+ for (const bad of ['000000000', '0000000000000', '12345678', '1234567890', '11553617x'])
+ cases.push(bad);
+
+ for (const c of cases) ins.run(c);
+ const rows = db.prepare(`SELECT eik_clean AS e, ${expr} AS v FROM probe`).all();
+ db.close();
+
+ assert.ok(rows.length >= 120, `expected a broad probe set, got ${rows.length}`);
+ for (const { e, v } of rows) {
+ assert.equal(
+ eikChecksumValid(e),
+ v === 1,
+ `disagreement on ${JSON.stringify(e)} (SQL said ${v})`,
+ );
+ }
+});
+
+/**
+ * Lift the `CASE … END AS eik_valid` expression straight out of normalize-raw.sql, so the test
+ * compares against the FILE and not a copy of it. A copy would drift with the thing it is pinning.
+ */
+function extractEikValidExpression(sql) {
+ const start = sql.indexOf(' CASE\n WHEN eik_clean IS NULL');
+ assert.ok(start > 0, 'eik_valid CASE not found in normalize-raw.sql — the pin lost its anchor');
+ const end = sql.indexOf('AS eik_valid', start);
+ assert.ok(end > start, 'eik_valid terminator not found');
+ return sql.slice(start, end);
+}
diff --git a/scripts/tr/evidence.mjs b/scripts/tr/evidence.mjs
new file mode 100644
index 000000000..56bc01efd
--- /dev/null
+++ b/scripts/tr/evidence.mjs
@@ -0,0 +1,306 @@
+// The evidence ladder (issue #279 §5, ADR-0033 decision 1). Pure: deed in, verdict out. Zero network.
+//
+// Six outcomes, FIRST MATCH WINS:
+// 1 bar_joint_stock АД / ЕАД / КДА — never published, whatever follows
+// 2 document the declarant's full name is in a live CR_F_7/18/19/23_L entity
+// 3 confirmed declared seat == registered seat, or the declarant wrote the ЕИК
+// 4 refuted own stake only: absent from live state, and the live ownership record
+// predates the declared period — the register covers it and does not name them
+// 5 unknown everything else — held
+// 6 outside_tr not in the register at all (ДЗЗД, БУЛСТАТ associations) — held
+//
+// WHAT THIS ESTABLISHES, precisely: the identity of the COMPANY — that the company behind the declared
+// name is the same legal entity as the winner we matched. It does NOT establish that the official owns
+// it; that claim comes from their own filed declaration and is not a heuristic at all. The failure mode
+// of a wrong match is therefore not an invented ownership claim but a real official attached to the
+// WRONG company's ЕИК, contracts and money. Still a false public statement about a named person, which
+// is why rung 2 requires a full three-token subset match inside a single registry entity, and why the
+// filters that can only withhold are kept (ADR-0033 decision 2).
+
+import {
+ liveFields,
+ fullSubsetMatch,
+ personTokens,
+ normalizeSettlement,
+ registrySeat,
+ registryLegalForm,
+ latestOwnershipEntryDate,
+ OWNERSHIP_FIELDS,
+ MANAGER_FIELD,
+ ROLE_FIELDS,
+} from './deed.mjs';
+
+/**
+ * Version of the RULES, not of the code. §8's monotonicity gate keys on this: a previously published
+ * link disappearing under an UNCHANGED rules version is a hard finding; under a changed one it is an
+ * expected diff. Bump it whenever a rung's meaning changes.
+ */
+export const RULES_VERSION = 'tr-rules-1';
+
+/** Rung 2 needs a real three-part Bulgarian name (ЗГР чл. 9). Two tokens is the homonym risk itself. */
+const MIN_NAME_TOKENS = 3;
+
+/**
+ * The CLOSED vocabulary a sealed `matched_fact` may take: `seat:
`, `role:owner:`,
+ * `role:manager:`, or `eik`. It must NEVER carry the matched NAME — the deed's names are read
+ * only to produce a boolean and never leave git-ignored scratch (#279 §9, ADR-0033 decision 5).
+ *
+ * The seat token bound is the whole rail. `seat:` is a legitimate prefix, so an unbounded settlement
+ * pattern admits `seat:ИВАН ПЕТРОВ ГЕОРГИЕВ` — a full three-part Bulgarian name (ЗГР чл. 9) wearing an
+ * allowed prefix, which is exactly the value a mis-split of the seat field would produce and exactly
+ * what the rail exists to reject. A settlement is one or two tokens („СОФИЯ", „ВЕЛИКО ТЪРНОВО",
+ * „ГЕНЕРАЛ ТОШЕВО"); a three-part name is exactly three. Bounding at two separates them cleanly, and a
+ * rarer 3-token seat stops the run for a human rather than publishing — the correct direction for a rail
+ * whose failure mode is putting somebody's name on a served column.
+ *
+ * Defined ONCE and consumed by both the writer (load.mjs) and the audit, so the two cannot drift into
+ * a state where the gate permits what the writer emits.
+ */
+export const MATCHED_FACT_RE =
+ /^(?:seat:\p{Lu}[\p{Lu}-]*(?: \p{Lu}[\p{Lu}-]*)?|role:(?:owner|manager):CR_F_\d+[a-z]?_L|eik)$/u;
+
+/** True when `fact` is a member of the closed vocabulary. `null` is legal — a rung may match no fact. */
+export function isSealedFact(fact) {
+ return fact == null || MATCHED_FACT_RE.test(String(fact));
+}
+
+// Court-registered companies were re-registered into the Търговски регистър in a single administrative
+// push, which flattened their entry dates into this window. „Strictly before the declared period"
+// certifies nothing when the date is an artefact of the migration rather than of the ownership, so the
+// refutation rung is suppressed inside it (R13). A suppressed refutation falls through to `unknown` —
+// held, not published, which is the safe direction.
+const REREGISTRATION_START = '2011-01-01';
+const REREGISTRATION_END = '2012-12-31';
+
+/**
+ * Find the declarant inside the live entities of the given field codes.
+ * Matching happens per ENTITY — never against a whole field — because one field routinely holds
+ * several people and combining tokens across them is the libel bug.
+ * @returns {{nameCode:string, entryNumber:string|null, entryDate:string|null}|null}
+ */
+function findPerson(deed, name, nameCodes) {
+ for (const f of liveFields(deed, nameCodes)) {
+ for (const entity of f.entities) {
+ if (fullSubsetMatch(name, entity)) {
+ return { nameCode: f.nameCode, entryNumber: f.entryNumber, entryDate: f.entryDate };
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * The registered seat, when it matches one THIS person declared for THIS company and was in force for the
+ * declared period. Shared by rung 3 (which publishes on it) and rung 2's company-identity corroborator.
+ *
+ * R10: seats move. A company that relocated INTO the declared settlement afterwards would confirm falsely,
+ * so the registered seat must predate the period.
+ *
+ * A null `firstDeclaredYear` FAILS the guard rather than skipping it. `load.mjs` passes null whenever no
+ * history row carried a parseable year, and an unknown year is not a satisfied temporal test — it is the
+ * absence of one. Reading it as „covers the period" made the weakest rung the only one with no temporal
+ * check, on exactly the links where we know least, and rung 4 already refuses to run without a year on the
+ * same ground. The undated-SEAT leg is different and stays: a seat with no entry date is the ordinary shape
+ * for a company that never moved, and it is checkable — a known year is still on the other side.
+ *
+ * @returns {{settlement:string, entryDate:string|null}|null}
+ */
+function matchDeclaredSeat(deed, declaredSeats, firstDeclaredYear) {
+ const seat = registrySeat(deed);
+ // Empty NEVER matches — otherwise every link with no seat data on either side rubber-stamps itself.
+ if (seat.settlement === '') return null;
+ if (firstDeclaredYear == null) return null;
+ if (seat.entryDate != null && seat.entryDate > `${firstDeclaredYear}-12-31`) return null;
+ const declared = declaredSeats.map(normalizeSettlement).filter((s) => s !== '');
+ return declared.includes(seat.settlement) ? seat : null;
+}
+
+/**
+ * Decide the evidence for one link.
+ *
+ * @param {object} input
+ * @param {object|null} input.deed parsed deed JSON; null only when `outsideTr`
+ * @param {boolean} [input.outsideTr] the ЕИК is not in the register at all
+ * @param {string} input.declarantName the office-holder's name as filed
+ * @param {string[]} [input.declaredSeats] seats declared BY THIS PERSON FOR THIS COMPANY only —
+ * 4.9% of company-name keys carry more than one distinct
+ * declared seat, so a company-only key would let one
+ * person's seat confirm another person's link
+ * @param {boolean} [input.declaredEik] the declarant wrote the ЕИК in the declaration
+ * @param {number|null} [input.firstDeclaredYear]
+ * @param {'self'|'family'} [input.scope]
+ * @param {boolean} [input.nameGloballyUnique] AND-gate on the WEAKEST rung only
+ * @param {boolean} [input.companyNameDistinctive] the declared фирма is unlikely to have a national
+ * twin. Gates an UNCORROBORATED rung 2 (ADR-0035).
+ * Defaults to FALSE: a caller that forgets it withholds.
+ * @returns {{kind:string, publishable:boolean, registryRole:string|null, matchedFact:string|null,
+ * entryNumber:string|null, entryDate:string|null, rulesVersion:string,
+ * shortName:boolean, latinInName:boolean}}
+ */
+export function evidenceVerdict(input) {
+ const {
+ deed,
+ outsideTr = false,
+ declarantName,
+ declaredSeats = [],
+ declaredEik = false,
+ firstDeclaredYear = null,
+ scope = 'self',
+ nameGloballyUnique = true,
+ // Fail-CLOSED, unlike `nameGloballyUnique` above. That one's permissive default is bounded — it gates
+ // only the weakest rung. This one gates the PRIMARY publishing rung, so a caller that forgets to pass
+ // it must withhold rather than publish a claim naming a real person against a company we did not
+ // establish. There is exactly one production caller (load.mjs) and it passes it explicitly.
+ companyNameDistinctive = false,
+ } = input;
+
+ const tokens = personTokens(declarantName);
+ const telemetry = {
+ rulesVersion: RULES_VERSION,
+ // Counted, not silently dropped: a refusal we cannot see is a recall hole nobody can size.
+ shortName: tokens.length < MIN_NAME_TOKENS,
+ latinInName: /[A-Za-z]/.test(String(declarantName ?? '')),
+ };
+ const verdict = (kind, publishable, extra = {}) => ({
+ kind,
+ publishable,
+ registryRole: null,
+ matchedFact: null,
+ entryNumber: null,
+ entryDate: null,
+ ...telemetry,
+ ...extra,
+ });
+
+ if (outsideTr) return verdict('outside_tr', false);
+ if (deed == null) {
+ // Fail closed and loudly. A missing deed quietly downgraded to „unknown" is indistinguishable
+ // from a real hold, and hides a cache gap that should stop the run.
+ throw new Error('evidenceVerdict: deed is required unless outsideTr is set');
+ }
+
+ // ── rung 1 ──────────────────────────────────────────────────────────────────
+ // A union of the numeric code and the ЗТРРЮЛНЦ suffix; either saying joint-stock bars the link, and
+ // neither able to say means we withhold rather than guess.
+ const form = registryLegalForm(deed);
+ if (form.verdict === 'joint_stock') return verdict('bar_joint_stock', false);
+ if (form.verdict === 'unknown') return verdict('unknown', false);
+
+ // The registered seat, matched against what THIS person declared for THIS company, with R10's temporal
+ // guard applied. Computed once and consumed by two rungs: rung 3 publishes „Потвърдено" on it, and rung 2
+ // uses it as a COMPANY-IDENTITY corroborator. One implementation, because two copies of "what counts as a
+ // seat match" would eventually disagree about which links may be published.
+ const matchedSeat = matchDeclaredSeat(deed, declaredSeats, firstDeclaredYear);
+
+ // ── rung 2 ──────────────────────────────────────────────────────────────────
+ // Only a full three-token name may assert. A Latin homoglyph makes the name a non-match rather than
+ // a false match — company-name-key.ts's posture, applied to people.
+ //
+ // The company gate (ADR-0035). A name match proves someone with these three tokens is registered in the
+ // company we LOOKED UP — never that this is the company the official declared. `resolveEntity` picks the
+ // sole WINNER holding the declared name and `nameGloballyUnique` ranges over bidders only, so an official
+ // whose real company never bid resolves to a same-named winner, and a homonym in that winner's deed
+ // completes a link false in both halves. Before rung 2 may assert, something other than the фирма must
+ // say the company is the declared one:
+ // • the declarant wrote the ЕИК — the national identifier resolves it outright (ADR-0028); or
+ // • the declared seat matches the registered one — a twin in another town is excluded; or
+ // • the фирма is distinctive enough that a national twin is improbable in the first place.
+ // The third is a bound, not a proof, and it is COUNTED (`documentUncorroborated`) so F8 can decide from
+ // the measured residual whether to tighten to the first two. Strict corroboration was the alternative;
+ // declared seats exist only on the ООД/ЕООД table of asset declarations, so its recall cost cannot be
+ // known before that measurement.
+ const companyCorroborated = declaredEik || matchedSeat != null;
+ const eligibleForDocument = !telemetry.shortName && !telemetry.latinInName;
+ if (eligibleForDocument) {
+ const owner = findPerson(deed, declarantName, OWNERSHIP_FIELDS);
+ const manager = owner ? null : findPerson(deed, declarantName, [MANAGER_FIELD]);
+ const hit = owner ?? manager;
+ if (hit && !companyCorroborated && !companyNameDistinctive) {
+ // A DISTINCT withholding kind, not a fall-through to `unknown`. „We matched a person but could not
+ // establish the company" and „we matched nothing" are different facts about a link, and the review
+ // queue (which is sealed for held links precisely to be reviewable) has to be able to tell them
+ // apart. It never publishes, and it carries no role or fact — asserting either would leak the very
+ // claim the rung just refused to make.
+ return verdict('document_uncorroborated', false);
+ }
+ if (owner) {
+ return verdict('document', true, {
+ registryRole: 'owner',
+ matchedFact: `role:owner:${owner.nameCode}`,
+ entryNumber: owner.entryNumber,
+ entryDate: owner.entryDate,
+ });
+ }
+ if (manager) {
+ return verdict('document', true, {
+ registryRole: 'manager',
+ matchedFact: `role:manager:${manager.nameCode}`,
+ entryNumber: manager.entryNumber,
+ entryDate: manager.entryDate,
+ });
+ }
+ }
+
+ // ── rung 3 ──────────────────────────────────────────────────────────────────
+ // The weakest publishing rung, so it carries the extra AND-gate: a nationally shared company name
+ // cannot ride it (ADR-0017's holding, carried forward). The stronger „Документ" rung above is not
+ // gated — the register named this person in THIS company, which makes the name key moot.
+ // The declared-ЕИК leg is NOT name-gated. ADR-0028: the ЕИК is the identity, not the name, so it
+ // resolves the company deterministically even behind a nationally shared фирма — which is exactly the
+ // case ADR-0017 was written about. Gating it on name uniqueness would discard the strongest
+ // identifier we have precisely where it is most needed.
+ if (declaredEik) return verdict('confirmed', true, { matchedFact: 'eik' });
+
+ // The SEAT leg is name-gated, and only this one. ADR-0017's holding carried forward: a name shared by
+ // two ЕИК cannot support a name-derived identity claim. The seat still rescues a GENERIC name — that
+ // is the whole point of the rung — it just cannot rescue a NATIONALLY SHARED one.
+ if (nameGloballyUnique && matchedSeat != null) {
+ return verdict('confirmed', true, {
+ matchedFact: `seat:${matchedSeat.settlement}`,
+ entryDate: matchedSeat.entryDate,
+ });
+ }
+
+ // ── rung 4 ──────────────────────────────────────────────────────────────────
+ // OWN stakes only. For a family stake the registered owner is the relative, whose name we neither
+ // store nor check, so absence of the OFFICIAL from the deed is evidence of nothing. An early branch,
+ // not a caller convention.
+ if (scope === 'self' && firstDeclaredYear != null) {
+ const stillPresent = findPerson(deed, declarantName, ROLE_FIELDS);
+ const latest = latestOwnershipEntryDate(deed);
+ const inRereg =
+ latest != null && latest >= REREGISTRATION_START && latest <= REREGISTRATION_END;
+ if (!stillPresent && latest != null && !inRereg && latest < `${firstDeclaredYear}-01-01`) {
+ return verdict('refuted', false, { entryDate: latest });
+ }
+ }
+
+ // ── rung 5 ──────────────────────────────────────────────────────────────────
+ return verdict('unknown', false);
+}
+
+/**
+ * Reconcile a DECLARED termination against the live deed (#279 §7).
+ *
+ * „Terminated" is an inference from silence — the commonest cause is a finished mandate, not a sale —
+ * so ADR-0021 E11's withdrawal is checked against the register before it takes effect.
+ *
+ * Phase 1 uses `terminated` ONLY. `label` is computed but deliberately not rendered: „и към днешна
+ * дата" asserts a present tense about a named person on evidence whose freshness is bounded by the
+ * cache refresh cycle, and it is deferred behind an LIA addendum (ADR-0033 decision 4).
+ *
+ * @returns {{terminated:boolean, label:'owner_today'|'manager_today'|null}}
+ */
+export function reconcileTermination({ deed, declarantName, scope = 'self' }) {
+ // Family first, structurally: there is nothing to look for, and looking would be an attempt to
+ // identify the relative.
+ if (scope !== 'self' || deed == null) return { terminated: true, label: null };
+
+ if (findPerson(deed, declarantName, OWNERSHIP_FIELDS)) {
+ return { terminated: false, label: 'owner_today' };
+ }
+ if (findPerson(deed, declarantName, [MANAGER_FIELD])) {
+ return { terminated: true, label: 'manager_today' };
+ }
+ return { terminated: true, label: null };
+}
diff --git a/scripts/tr/evidence.test.mjs b/scripts/tr/evidence.test.mjs
new file mode 100644
index 000000000..d3f71085c
--- /dev/null
+++ b/scripts/tr/evidence.test.mjs
@@ -0,0 +1,564 @@
+// node:test — the evidence ladder (ADR-0033 decision 1). Pure: deed in, verdict out.
+//
+// Six outcomes, first match wins. What each rung is allowed to CONCLUDE is the whole subject:
+// the registry proves the identity of the COMPANY, never that the official owns it — the ownership
+// claim comes from the official's own filed declaration. So a wrong match here does not invent an
+// ownership claim, it attaches a real official to the wrong company's ЕИК, contracts and money.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ RULES_VERSION,
+ evidenceVerdict,
+ reconcileTermination,
+ MATCHED_FACT_RE,
+ isSealedFact,
+} from './evidence.mjs';
+
+const container = (t) =>
+ ``;
+const joined = (...ts) => ts.map(container).join(`
`);
+const ERASED = ``;
+
+const fld = (nameCode, htmlData, entryDate = '2011-05-02T00:00:00') => ({
+ nameCode,
+ htmlData,
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: entryDate,
+ fieldOperation: 3,
+});
+const deed = (fields, over = {}) => ({
+ uic: '201122335',
+ fullName: '"АЛФА СТРОЙ" ООД',
+ legalForm: 4,
+ sections: [{ subDeeds: [{ groups: [{ fields }] }] }],
+ ...over,
+});
+
+const OWNER_DEED = deed([
+ fld('CR_F_19_L', joined('ИВАН ПЕТРОВ ТЕСТОВ, Държава: БЪЛГАРИЯ', 'МАРИЯ СТОЯНОВА ИВАНОВА')),
+ fld('CR_F_5_L', container('Държава: БЪЛГАРИЯ
Населено място: гр. Пловдив, п.к. 4000')),
+]);
+
+const base = {
+ deed: OWNER_DEED,
+ declarantName: 'Иван Петров Тестов',
+ declaredSeats: [],
+ declaredEik: false,
+ firstDeclaredYear: 2021,
+ scope: 'self',
+ nameGloballyUnique: true,
+ // The company was resolved by a name unlikely to have a national twin. The rung-2 tests below are about
+ // NAME matching inside a deed, so they hold this dimension fixed; the gate itself is tested separately.
+ companyNameDistinctive: true,
+};
+
+test('RULES_VERSION is a stable, non-empty identifier — §8 hangs off it', () => {
+ assert.equal(typeof RULES_VERSION, 'string');
+ assert.ok(RULES_VERSION.length > 0);
+});
+
+// ── rung 1: the joint-stock bar wins over everything ──────────────────────────
+test('rung 1 — a joint-stock company is barred even when the person IS in the deed', () => {
+ const ad = deed([fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))], {
+ legalForm: 5,
+ fullName: '"ГАМА ИНВЕСТ" АД',
+ });
+ const v = evidenceVerdict({ ...base, deed: ad });
+ assert.equal(v.kind, 'bar_joint_stock');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 1 — an UNKNOWN legal form withholds; it never falls through to a lower rung', () => {
+ const odd = deed([fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))], {
+ legalForm: 99,
+ fullName: 'НЕЩО БЕЗ ФОРМА',
+ });
+ const v = evidenceVerdict({ ...base, deed: odd });
+ assert.equal(v.kind, 'unknown');
+ assert.equal(v.publishable, false);
+});
+
+// ── rung 2: „Документ" ────────────────────────────────────────────────────────
+test('rung 2 — a full-name match in a live ownership field publishes, with the role kept', () => {
+ const v = evidenceVerdict(base);
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+ assert.equal(v.registryRole, 'owner');
+ assert.equal(v.matchedFact, 'role:owner:CR_F_19_L');
+ assert.equal(v.entryNumber, '20110502101007');
+ assert.equal(v.entryDate, '2011-05-02');
+});
+
+test('rung 2 — a manager-only match publishes but records the weaker role', () => {
+ const mgr = deed([
+ fld('CR_F_7_L', container('ИВАН ПЕТРОВ ТЕСТОВ, Държава: БЪЛГАРИЯ')),
+ fld('CR_F_19_L', container('ДРУГО ЛИЦЕ ТУК')),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: mgr });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.registryRole, 'manager');
+ assert.equal(v.matchedFact, 'role:manager:CR_F_7_L');
+});
+
+test('rung 2 — a TWO-token declarant can never earn „Документ"', () => {
+ // 46 of 301 measured matches were two-token only, which is exactly the homonym risk. Falls to a
+ // lower rung rather than publishing on a name that half a register could satisfy.
+ const two = deed([fld('CR_F_19_L', container('ИВАН ТЕСТОВ, Държава: БЪЛГАРИЯ'))]);
+ const v = evidenceVerdict({ ...base, deed: two, declarantName: 'Иван Тестов' });
+ assert.notEqual(v.kind, 'document');
+ assert.equal(v.shortName, true, 'the refusal is counted, not silently dropped');
+});
+
+test('rung 2 — the match must fall inside ONE entity (the libel guard, end to end)', () => {
+ const two = deed([
+ fld('CR_F_19_L', joined('ПЕНКО НЕСТОРОВ НЕСТОРОВ', 'ИЛИЯН КОСТАДИНОВ ФИЛИПОВ')),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: two, declarantName: 'ПЕНКО КОСТАДИНОВ ФИЛИПОВ' });
+ assert.notEqual(v.kind, 'document');
+});
+
+test('rung 2 — an ERASED ownership entry cannot produce a document match', () => {
+ const gone = deed([fld('CR_F_23_L', ERASED, '2013-07-16T10:10:07')]);
+ const v = evidenceVerdict({ ...base, deed: gone });
+ assert.notEqual(v.kind, 'document');
+});
+
+test('rung 2 — a Latin homoglyph in the name is a NON-match, and is counted', () => {
+ // company-name-key.ts deliberately does not fold Cyrillic↔Latin; person names take the same posture.
+ const v = evidenceVerdict({ ...base, declarantName: 'ИBAH ПЕТРОВ ТЕСТОВ' }); // Latin B, A, H
+ assert.notEqual(v.kind, 'document');
+ assert.equal(v.latinInName, true);
+});
+
+// ── rung 3: „Потвърдено" ──────────────────────────────────────────────────────
+test('rung 3 — a declared seat matching the registered seat confirms the company', () => {
+ const other = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив, п.к. 4000'), '2015-01-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: other, declaredSeats: ['Пловдив'] });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.publishable, true);
+ assert.equal(v.matchedFact, 'seat:ПЛОВДИВ');
+});
+
+test('rung 3 — a declared ЕИК confirms the company on its own', () => {
+ const other = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК'))]);
+ const v = evidenceVerdict({ ...base, deed: other, declaredEik: true });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'eik');
+});
+
+test('rung 3 — an EMPTY declared seat never confirms', () => {
+ const noSeat = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК'))]);
+ const v = evidenceVerdict({ ...base, deed: noSeat, declaredSeats: ['', ' '] });
+ assert.notEqual(v.kind, 'confirmed');
+});
+
+test('rung 3 — a seat registered AFTER the declared period does not confirm', () => {
+ // R10, and W0 measured that seats move: a company that relocated INTO the declared settlement after
+ // the fact would otherwise produce a false „Потвърдено".
+ const moved = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив'), '2024-06-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: moved,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: 2021,
+ });
+ assert.notEqual(v.kind, 'confirmed');
+});
+
+test('rung 3 — an UNKNOWN first declared year cannot confirm on a seat', () => {
+ // R10 again, from the other side. `load.mjs` passes `firstDeclaredYear: null` whenever no history row
+ // carried a parseable year, and a null year means the temporal check has NOTHING to compare against —
+ // not that the seat covers the period. The same relocated company as the test above, with the year
+ // unknown instead of 2021, must reach the same held outcome: an unknown guard is a failed guard.
+ const moved = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив'), '2024-06-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: moved,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: null,
+ });
+ assert.notEqual(v.kind, 'confirmed');
+});
+
+test('rung 3 — an unknown year holds a seat match even when the seat has NO entry date', () => {
+ // The nastier half: with no entry date on the register side AND no year on the declaration side there
+ // are two unknowns and zero evidence about the period, yet both legs of the old disjunction read TRUE.
+ // Rung 4's refutation leg already refuses to run without a year (`firstDeclaredYear != null`); the seat
+ // leg must refuse on the same ground, or the weakest rung is the one with no temporal check at all.
+ const undated = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: undated,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: null,
+ });
+ assert.notEqual(v.kind, 'confirmed', 'two unknowns must not multiply into a public claim');
+});
+
+test('rung 3 — a KNOWN year with an undated seat still confirms (the guard is not a blanket)', () => {
+ // Positive control. Bounding the null case must not quietly kill the rung: a seat with no entry date
+ // is the ordinary shape for a company that never moved, and it still confirms under a known year.
+ const undated = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: undated,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: 2021,
+ });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'seat:ПЛОВДИВ');
+});
+
+test('rung 3 — the weakest rung ALSO requires global name uniqueness (ADR-0017 carried forward)', () => {
+ const other = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: other,
+ declaredSeats: ['Пловдив'],
+ nameGloballyUnique: false,
+ });
+ assert.notEqual(v.kind, 'confirmed', 'a nationally shared name cannot ride the weakest rung');
+});
+
+test('rung 3 — a declared ЕИК is NOT gated by name uniqueness (ADR-0028)', () => {
+ // The case ADR-0017 was written about — a фирма backing two ЕИК — is exactly where a declarant-supplied
+ // ЕИК is most valuable. Gating it on the name would discard the strongest identifier precisely when the
+ // name is useless.
+ const other = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК'))]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: other,
+ declaredEik: true,
+ nameGloballyUnique: false,
+ });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'eik');
+});
+
+test('rung 3 — the seat rung DOES rescue a merely generic name; it is uniqueness that gates it', () => {
+ // The seat leg exists to rescue generic names (a bare one- or two-word фирма). Requiring the name to
+ // be distinctive would empty the rung of its entire purpose; only NATIONAL non-uniqueness blocks it.
+ const generic = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: generic,
+ declaredSeats: ['Пловдив'],
+ nameGloballyUnique: true,
+ });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'seat:ПЛОВДИВ');
+});
+
+test('rung 3 — name uniqueness does NOT gate the stronger „Документ" rung', () => {
+ const v = evidenceVerdict({ ...base, nameGloballyUnique: false });
+ assert.equal(
+ v.kind,
+ 'document',
+ 'the registry named the person in THIS company; the name key is moot',
+ );
+});
+
+test('rung 2 — every OWNERSHIP field code can carry the match, not just CR_F_19_L', () => {
+ // OWNERSHIP_FIELDS is ['CR_F_18_L','CR_F_19_L','CR_F_23_L'] — едноличен собственик, съдружници, and
+ // ФЛ-търговец. Every owner test used CR_F_19_L, so a typo or a dropped entry in the other two would
+ // have silently withheld an entire ownership shape: a sole owner (the commonest ЕООД form) publishing
+ // as „Неизвестна" is a recall hole with no symptom.
+ for (const code of ['CR_F_18_L', 'CR_F_19_L', 'CR_F_23_L']) {
+ const d = deed([fld(code, container('ИВАН ПЕТРОВ ТЕСТОВ'))]);
+ const v = evidenceVerdict({ ...base, deed: d });
+ assert.equal(v.kind, 'document', `${code} must carry an ownership match`);
+ assert.equal(v.registryRole, 'owner', `${code} is an OWNERSHIP field, not management`);
+ assert.equal(v.matchedFact, `role:owner:${code}`);
+ }
+ // POSITIVE CONTROL — a field that is NOT an ownership or manager field must not match at all, or the
+ // loop above would pass for a reason other than the one it claims.
+ const other = deed([fld('CR_F_99_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))]);
+ assert.notEqual(evidenceVerdict({ ...base, deed: other }).kind, 'document');
+});
+
+// ── rung 2's company gate: the winner-vs-non-winner homonym (ADR-0035) ────────
+//
+// The ladder proves a person with these three tokens is registered in the company we LOOKED UP. It cannot
+// prove that company is the one the official declared. `resolveEntity` maps a declared name to the sole
+// WINNER holding it, and `nameGloballyUnique` ranges over procurement bidders only — never the whole
+// register. So when an official owns a same-named company that never bid, we resolve to the winner, and a
+// three-token homonym in the winner's deed „proves" a link that is false in both halves.
+//
+// Two coincidences, and neither is rare in Bulgaria: a shared фирма and a shared three-part name. The gate
+// asks for a reason to believe the COMPANY is the declared one before rung 2 may assert.
+test('rung 2 — a GENERIC company name with no corroboration cannot publish on a name match alone', () => {
+ const v = evidenceVerdict({ ...base, companyNameDistinctive: false });
+ assert.equal(
+ v.kind,
+ 'document_uncorroborated',
+ 'the deed names SOMEONE with this name in THIS company — not that this is the declared company',
+ );
+ assert.equal(v.publishable, false);
+ // It must not fall through to `unknown`: the residual is the input to F8's decision on whether this gate
+ // tightens, and a rung-2 match withheld for want of company identity is a different fact from no match.
+ assert.equal(v.registryRole, null);
+ assert.equal(v.matchedFact, null);
+});
+
+test('rung 2 — a declared ЕИК corroborates the company, so the name match publishes', () => {
+ // POSITIVE CONTROL. The ЕИК IS the identity (ЗТРРЮЛНЦ, ADR-0028) — it resolves the company behind any
+ // shared фирма, which is exactly the collision the gate is about.
+ const v = evidenceVerdict({ ...base, companyNameDistinctive: false, declaredEik: true });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+ assert.equal(v.registryRole, 'owner');
+});
+
+test('rung 2 — a declared seat matching the registered seat corroborates the company', () => {
+ // POSITIVE CONTROL. The declarant put this company in гр. Пловдив and the register agrees; a national
+ // twin in another town is excluded by the same fact rung 3 publishes on.
+ const v = evidenceVerdict({
+ ...base,
+ companyNameDistinctive: false,
+ declaredSeats: ['гр. Пловдив'],
+ });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+});
+
+test('rung 2 — a DISTINCTIVE company name publishes uncorroborated (the gate is not a blanket)', () => {
+ // POSITIVE CONTROL, and the one that distinguishes this fix from disabling rung 2. A predicate that
+ // always withheld would satisfy the bar above; this is what it must NOT do.
+ const v = evidenceVerdict({ ...base, companyNameDistinctive: true });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+});
+
+test('rung 2 — a seat that does NOT match cannot corroborate a generic name', () => {
+ // The corroborator has to actually corroborate. A declared seat in another town is evidence AGAINST the
+ // company being the declared one, so it certainly cannot rescue the rung.
+ const v = evidenceVerdict({
+ ...base,
+ companyNameDistinctive: false,
+ declaredSeats: ['гр. Бургас'], // the deed says Пловдив
+ });
+ assert.equal(v.kind, 'document_uncorroborated');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 2 — the seat corroborator carries the SAME temporal guard as rung 3 (R10)', () => {
+ // A seat registered after the declared period cannot corroborate anything: the company may have moved
+ // INTO that town afterwards. Rung 3 already refuses it; rungs 2 and 3 share one implementation so they
+ // cannot drift into disagreeing about what a seat match means.
+ const moved = deed([
+ fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ')),
+ fld(
+ 'CR_F_5_L',
+ container('Държава: БЪЛГАРИЯ
Населено място: гр. Пловдив, п.к. 4000'),
+ '2023-07-01T00:00:00',
+ ),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: moved,
+ companyNameDistinctive: false,
+ declaredSeats: ['гр. Пловдив'],
+ firstDeclaredYear: 2021,
+ });
+ assert.equal(v.kind, 'document_uncorroborated');
+});
+
+test('rung 2 — the company gate never rescues a link rung 1 has barred', () => {
+ // Ordering: a joint-stock bar outranks everything, corroborated or not. The gate adds a way to WITHHOLD,
+ // never a way to publish something a stronger rung refused.
+ const ad = deed([fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))], {
+ legalForm: 5,
+ fullName: '"ГАМА ИНВЕСТ" АД',
+ });
+ const v = evidenceVerdict({ ...base, deed: ad, companyNameDistinctive: true, declaredEik: true });
+ assert.equal(v.kind, 'bar_joint_stock');
+});
+
+// ── rung 4: „Оборена" ─────────────────────────────────────────────────────────
+test('rung 4 — absent from a deed whose ownership predates the declaration refutes the link', () => {
+ const older = deed([
+ fld('CR_F_19_L', container('СЪВСЕМ ДРУГ СОБСТВЕНИК'), '2015-03-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: older, firstDeclaredYear: 2021 });
+ assert.equal(v.kind, 'refuted');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 4 — the comparison is date-to-DATE, not date-to-year', () => {
+ // R17: „strictly before the first declared year" means before YYYY-01-01. An entry inside the first
+ // declared year does NOT cover the period and must not refute.
+ const inYear = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2021-06-15T00:00:00')]);
+ assert.notEqual(
+ evidenceVerdict({ ...base, deed: inYear, firstDeclaredYear: 2021 }).kind,
+ 'refuted',
+ );
+ const justBefore = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2020-12-31T00:00:00')]);
+ assert.equal(
+ evidenceVerdict({ ...base, deed: justBefore, firstDeclaredYear: 2021 }).kind,
+ 'refuted',
+ );
+});
+
+test('rung 4 — NEVER applies to a family stake', () => {
+ // The owner there is the relative, whose name we neither store nor check (ADR-0010 item 4,
+ // ADR-0032 decision 2), so „the official is not in the deed" says nothing at all.
+ const older = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2015-03-01T00:00:00')]);
+ const v = evidenceVerdict({ ...base, deed: older, scope: 'family', firstDeclaredYear: 2021 });
+ assert.notEqual(v.kind, 'refuted');
+ assert.equal(v.kind, 'unknown');
+});
+
+test('rung 4 — suppressed inside the 2011–2012 re-registration window', () => {
+ // R13: court-registered companies had every entry date flattened into the re-registration window,
+ // so „strictly before" certifies nothing there.
+ const flattened = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2011-11-04T00:00:00')]);
+ const v = evidenceVerdict({ ...base, deed: flattened, firstDeclaredYear: 2021 });
+ assert.notEqual(v.kind, 'refuted');
+ assert.equal(v.kind, 'unknown');
+});
+
+// ── rungs 5 and 6 ─────────────────────────────────────────────────────────────
+test('rung 5 — everything else is „Неизвестна" and stays hidden', () => {
+ const recent = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2023-01-01T00:00:00')]);
+ const v = evidenceVerdict({ ...base, deed: recent, firstDeclaredYear: 2021 });
+ assert.equal(v.kind, 'unknown');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 6 — outside the register is its own outcome, and is not publishable', () => {
+ const v = evidenceVerdict({ ...base, deed: null, outsideTr: true });
+ assert.equal(v.kind, 'outside_tr');
+ assert.equal(v.publishable, false);
+});
+
+test('a missing deed that is NOT marked outside-ТР is an error, not a silent hold', () => {
+ // Fail closed: a cache gap must be visible, never quietly downgraded to „unknown".
+ assert.throws(() => evidenceVerdict({ ...base, deed: null, outsideTr: false }), /deed/i);
+});
+
+// ── the seal ──────────────────────────────────────────────────────────────────
+test('MATCHED_FACT_RE bounds a settlement to two tokens — a NAME cannot wear the seat: prefix', () => {
+ // The rail tested DIRECTLY, not just through whatever verdicts the ladder happens to produce. Both
+ // seal tests previously restated this regex locally and got it WRONG in the permissive direction —
+ // `seat:` followed by unlimited uppercase tokens — so a three-part Bulgarian name (ЗГР чл. 9) wearing
+ // an allowed prefix passed them. That value is exactly what a mis-split of the seat field produces,
+ // and it is the one shape this rail exists to keep off a served column.
+ for (const ok of [
+ 'seat:СОФИЯ',
+ 'seat:ВЕЛИКО ТЪРНОВО', // a real two-token settlement must still pass
+ 'seat:ГЕНЕРАЛ ТОШЕВО',
+ 'seat:ЦАР-КАЛОЯН', // hyphenated is one token
+ 'role:owner:CR_F_19_L',
+ 'role:manager:CR_F_7_L',
+ 'role:owner:CR_F_23_L',
+ 'eik',
+ ])
+ assert.equal(MATCHED_FACT_RE.test(ok), true, `wrongly rejected: ${ok}`);
+
+ for (const bad of [
+ 'seat:ИВАН ПЕТРОВ ГЕОРГИЕВ', // THE case: three tokens is a name, not a settlement
+ 'seat:ИВАН ПЕТРОВ ГЕОРГИЕВ ДРУГ',
+ 'ИВАН ПЕТРОВ ГЕОРГИЕВ', // a bare name with no prefix at all
+ 'role:owner:ИВАН ПЕТРОВ', // a name where a field code belongs
+ 'role:cashier:CR_F_19_L', // a role outside the vocabulary
+ 'seat:', // an empty settlement asserts nothing
+ 'eik:201122335', // the ЕИК itself is never stored, only the fact that one matched
+ ])
+ assert.equal(MATCHED_FACT_RE.test(bad), false, `wrongly accepted: ${bad}`);
+
+ // null is legal — a rung may match no fact — and that is isSealedFact's job, not the regex's.
+ assert.equal(isSealedFact(null), true);
+ assert.equal(isSealedFact('seat:ИВАН ПЕТРОВ ГЕОРГИЕВ'), false);
+});
+
+test('matched_fact stays inside the closed vocabulary — it can never carry a name', () => {
+ // The PRODUCTION predicate, imported — never a local copy of it. A re-stated regex here was looser
+ // than `MATCHED_FACT_RE` (it allowed `seat:` + unlimited tokens), so this loop certified values the
+ // real rail rejects and could not fail on the regression it exists to catch (cefothe, #309).
+ for (const v of [
+ evidenceVerdict(base),
+ evidenceVerdict({ ...base, deed: deed([fld('CR_F_7_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))]) }),
+ evidenceVerdict({ ...base, declaredEik: true }),
+ evidenceVerdict({
+ ...base,
+ deed: deed([
+ fld('CR_F_19_L', container('ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]),
+ declaredSeats: ['Пловдив'],
+ }),
+ ]) {
+ if (v.matchedFact == null) continue;
+ assert.ok(isSealedFact(v.matchedFact), `matched_fact escaped the vocabulary: ${v.matchedFact}`);
+ assert.ok(!/ИВАН|ПЕТРОВ|ТЕСТОВ/.test(v.matchedFact), 'a NAME reached matched_fact');
+ }
+});
+
+test('every verdict carries the rules version that produced it', () => {
+ assert.equal(evidenceVerdict(base).rulesVersion, RULES_VERSION);
+});
+
+// ── §7 reconciliation ─────────────────────────────────────────────────────────
+test('reconcileTermination — still a registered owner ⇒ NOT terminated', () => {
+ const r = reconcileTermination({
+ deed: OWNER_DEED,
+ declarantName: 'Иван Петров Тестов',
+ scope: 'self',
+ });
+ assert.equal(r.terminated, false);
+ assert.equal(r.label, 'owner_today');
+});
+
+test('reconcileTermination — manager only ⇒ terminated as a stake, but the tie continues', () => {
+ const mgr = deed([
+ fld('CR_F_7_L', container('ИВАН ПЕТРОВ ТЕСТОВ')),
+ fld('CR_F_19_L', container('ДРУГ')),
+ ]);
+ const r = reconcileTermination({ deed: mgr, declarantName: 'Иван Петров Тестов', scope: 'self' });
+ assert.equal(r.terminated, true);
+ assert.equal(r.label, 'manager_today');
+});
+
+test('reconcileTermination — absent from the live deed ⇒ the declared termination stands', () => {
+ const none = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ'))]);
+ const r = reconcileTermination({
+ deed: none,
+ declarantName: 'Иван Петров Тестов',
+ scope: 'self',
+ });
+ assert.equal(r.terminated, true);
+ assert.equal(r.label, null);
+});
+
+test('reconcileTermination — a FAMILY stake is never reconciled, by an early branch', () => {
+ // Structural, not a caller convention: the relative's name is not stored, so there is nothing to
+ // look for, and looking would be a de-anonymisation attempt.
+ const r = reconcileTermination({
+ deed: OWNER_DEED,
+ declarantName: 'Иван Петров Тестов',
+ scope: 'family',
+ });
+ assert.equal(r.terminated, true);
+ assert.equal(r.label, null);
+});
diff --git a/scripts/tr/fetch-deeds.mjs b/scripts/tr/fetch-deeds.mjs
new file mode 100644
index 000000000..cdea4f73f
--- /dev/null
+++ b/scripts/tr/fetch-deeds.mjs
@@ -0,0 +1,296 @@
+// The deed crawler (issue #279 §3, ADR-0033). One request per candidate ЕИК, sequential and paced.
+//
+// This is the only component in the project that touches a public register at volume, so what it
+// REFUSES to do is the substance:
+//
+// • It never goes faster than 1 request / 3 s, and the flag that sets the pace cannot be used to go
+// faster — only slower. Spec §3.3 permits a bounded per-ЕИК lookup and forbids bulk scraping; the
+// limiter is the operator's only way to state a preference, and tuning around it empirically is
+// what that rule exists to prevent.
+// • It never retries a 429. The block is sustained (see client.mjs), so a 429 ends the run with its
+// own exit code and records NOTHING about the ЕИК that hit it — that ЕИК is unknown, not absent.
+// • It never follows a link out of a deed. The candidate set is closed: whatever the caller passes
+// in, nothing more. This is what keeps a bounded lookup from drifting into a crawl.
+// • It only writes „outside the register" on a DOCUMENTED negative — measured to be an HTTP 200
+// with an empty body, not the 404 the issue predicts. A 5xx or a timeout is transient, and caching
+// it as permanent would turn an outage into data that §8 never revisits.
+//
+// Resumable by construction: the cache is consulted first, so an interrupted run picks up exactly
+// where it stopped and a complete cache costs zero requests.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { assertTrScratchIgnored, TR_DB, TR_RAW, safeEik, deedPath } from './paths.mjs';
+import { eikChecksumValid } from './eik.mjs';
+import { deedUrl, politeTrGet, RateLimitError, httpsGet } from './client.mjs';
+import {
+ openCache,
+ upsertDeed,
+ markOutsideTr,
+ pendingEiks,
+ purgeExpired,
+ RETENTION_DAYS,
+} from './cache.mjs';
+import {
+ assertUicEcho,
+ registryLegalForm,
+ registrySeat,
+ latestOwnershipEntryDate,
+} from './deed.mjs';
+
+/** The documented polite pace: 1 request / 3 s (#279 §3). A floor, never a target to tune down. */
+export const MIN_INTERVAL_MS = 3000;
+
+/**
+ * Consecutive unresolved ЕИК before the run gives up rather than keep hammering.
+ *
+ * Deliberately small, because the unit is ЕИК and not requests: each unresolved candidate costs up to
+ * 5 attempts (#279 §3's documented retry budget), so the breaker's real cost is `BREAKER_TRIP × 5`
+ * requests against an endpoint that is already failing. At 10 that would be ~50 — the exact volume at
+ * which the register was observed to start returning a sustained 429. At 5 the worst case is ~25,
+ * which the same observation saw pass without a block.
+ */
+export const BREAKER_TRIP = 5;
+/** Attempts per candidate, per #279 §3. Exported so the breaker's request budget is derivable. */
+export const TRIES_PER_EIK = 5;
+
+export function parseTrOptions(argv) {
+ const get = (name, def) => {
+ const i = argv.indexOf(`--${name}`);
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
+ };
+ const posInt = (raw, name) => {
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n < 1)
+ throw new Error(`--${name} must be a positive integer, got ${JSON.stringify(raw)}`);
+ return n;
+ };
+
+ const eiksFile = get('eiks-file', '');
+ if (!eiksFile) throw new Error('--eiks-file is required (the closed candidate set)');
+
+ const limitRaw = get('limit', '');
+ const intervalRaw = get('min-interval-ms', '');
+ const minIntervalMs = intervalRaw ? posInt(intervalRaw, 'min-interval-ms') : MIN_INTERVAL_MS;
+ // Slower is always allowed; faster is not a knob. Making this un-passable is the point.
+ if (minIntervalMs < MIN_INTERVAL_MS) {
+ throw new Error(
+ `--min-interval-ms may not go below the documented pace of ${MIN_INTERVAL_MS}ms — ` +
+ `the register's rate limit is a stated preference, not an obstacle to tune around`,
+ );
+ }
+ const maxAgeRaw = get('max-age-days', '');
+ // --max-age-days is FRESHNESS (re-request past it); --retention-days is RETENTION (delete past it).
+ // They are separate flags because they are separate obligations: refreshing rewrites the personal
+ // data, only purging removes it. Retention defaults to the ADR's 35 days rather than to „off", so
+ // the rail holds for an operator who passes neither.
+ const retentionRaw = get('retention-days', '');
+ return {
+ eiksFile,
+ limit: limitRaw ? posInt(limitRaw, 'limit') : Infinity,
+ minIntervalMs,
+ maxAgeDays: maxAgeRaw ? posInt(maxAgeRaw, 'max-age-days') : null,
+ retentionDays: retentionRaw ? posInt(retentionRaw, 'retention-days') : RETENTION_DAYS,
+ };
+}
+
+/** Read the closed candidate set: one ЕИК per line, blanks and `#` comments ignored. */
+export function readEiksFile(file) {
+ return fs
+ .readFileSync(file, 'utf8')
+ .split('\n')
+ .map((l) => l.trim())
+ .filter((l) => l !== '' && !l.startsWith('#'));
+}
+
+function atomicWrite(file, buf) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.tmp-${process.pid}`;
+ fs.writeFileSync(tmp, buf);
+ fs.renameSync(tmp, file);
+}
+
+/**
+ * Crawl the candidate ЕИК. Returns the intended process exit code, so the decision is testable
+ * without a global side effect:
+ * 0 — every candidate resolved (or the run was deliberately bounded by --limit)
+ * 1 — at least one candidate is unresolved (transient failure, refused deed, breaker tripped)
+ * 2 — the register rate-limited us; the run stopped and nothing was marked
+ *
+ * Every I/O edge is injectable so the whole policy is exercised offline.
+ */
+export async function run({
+ httpGet = httpsGet,
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
+ now = () => new Date(),
+ guard = assertTrScratchIgnored,
+ dbFile = TR_DB,
+ rawDir = TR_RAW,
+ argv = process.argv,
+} = {}) {
+ guard();
+ const { eiksFile, limit, minIntervalMs, maxAgeDays, retentionDays } = parseTrOptions(argv);
+
+ const requested = readEiksFile(eiksFile);
+ // A shape- or checksum-invalid code is dropped BEFORE any request: it cannot name a real company,
+ // so asking about it would spend the register's budget to learn nothing.
+ const candidates = [];
+ let invalid = 0;
+ for (const raw of requested) {
+ try {
+ const eik = safeEik(raw);
+ if (!eikChecksumValid(eik)) throw new Error('checksum');
+ candidates.push(eik);
+ } catch {
+ invalid++;
+ }
+ }
+
+ const db = openCache(dbFile);
+ try {
+ const pending = pendingEiks(db, candidates, { maxAgeDays, now: now() });
+ const todo = Number.isFinite(limit) ? pending.slice(0, limit) : pending;
+ console.log(
+ `candidates ${candidates.length} · invalid ${invalid} · cached ${candidates.length - pending.length} · to fetch ${todo.length}`,
+ );
+
+ let unresolved = 0;
+ let consecutive = 0;
+ let first = true;
+
+ for (const eik of todo) {
+ if (!first) await sleep(minIntervalMs); // pace BETWEEN requests, not before the first
+ first = false;
+
+ let res;
+ try {
+ res = await politeTrGet(deedUrl(eik), { httpGet, sleep, tries: TRIES_PER_EIK });
+ } catch (err) {
+ if (err instanceof RateLimitError) {
+ // Stop the whole run. Recording anything here would attribute the register's throttle to
+ // this ЕИК, which is a fact about us, not about the company.
+ console.error(`${err.message}\nSTOPPING — re-run later; progress so far is cached.`);
+ return 2;
+ }
+ console.error(
+ ` ${eik}: ${err instanceof Error ? err.message : err} (transient, not cached)`,
+ );
+ unresolved++;
+ consecutive++;
+ if (consecutive >= BREAKER_TRIP) {
+ console.error(`breaker: ${consecutive} consecutive failures — aborting the run`);
+ return 1;
+ }
+ continue;
+ }
+
+ // ── the documented negatives ────────────────────────────────────────────
+ // MEASURED 2026-08-05: an ЕИК that is not a търговец answers **HTTP 200 with a ZERO-BYTE body**,
+ // not a 404 and not the HTML #279 §3 predicts. Verified on Община София (000696327): empty on
+ // two consecutive requests, while a real company returned its full 34,398-byte deed in the same
+ // window — so it is the register's answer, not an outage.
+ //
+ // The distinction that keeps R6 honest is the STATUS, not the empty body: an empty body under
+ // 200 is the register saying „no deed"; an empty body under 5xx is a failure and stays
+ // transient. Getting this backwards either caches a false negative forever or leaves ~4 ЕИК
+ // permanently unresolved so the run can never exit 0.
+ if (res.status === 200 && res.body.length === 0) {
+ markOutsideTr(db, eik, 'HTTP 200, empty body — no deed in the Търговски регистър', now());
+ consecutive = 0;
+ continue;
+ }
+ if (res.status === 404) {
+ markOutsideTr(db, eik, 'HTTP 404 — not in the Търговски регистър (BULSTAT/ДЗЗД?)', now());
+ consecutive = 0;
+ continue;
+ }
+ if (res.status !== 200) {
+ console.error(` ${eik}: HTTP ${res.status} after retries (transient, not cached)`);
+ unresolved++;
+ consecutive++;
+ if (consecutive >= BREAKER_TRIP) {
+ console.error(`breaker: ${consecutive} consecutive failures — aborting the run`);
+ return 1;
+ }
+ continue;
+ }
+
+ // ONE refuse-and-continue block around EVERYTHING derived from the response — the JSON, the UIC
+ // echo, and the HTML parsing alike. The parsing used to sit outside it, which made the block's
+ // own promise false: a throw from deed.mjs escaped the loop, escaped run(), and killed the
+ // process, so one malformed deed ended a crawl that had already spent its paced request budget.
+ // The decode guard in deed.mjs removes the one throw we know of; this is the rail that holds when
+ // the next one appears, and the cost of being wrong here is measured in hours of pacing.
+ try {
+ const deed = JSON.parse(res.body.toString('utf8'));
+ // The deed we got back must be the deed we asked for, or every claim derived from it names
+ // the wrong company (R8).
+ assertUicEcho(deed, eik);
+
+ // The raw response is the ONLY place names live; it stays under git-ignored scratch. Written
+ // only after the echo check, so a deed for the wrong company never lands on disk.
+ atomicWrite(deedPath(eik, rawDir), res.body);
+ const seat = registrySeat(deed);
+ const form = registryLegalForm(deed);
+ upsertDeed(db, {
+ eik,
+ status: 'fetched',
+ httpStatus: 200,
+ fetchedAt: now().toISOString(),
+ rawPath: path.relative(rawDir, deedPath(eik, rawDir)),
+ bodySha256: crypto.createHash('sha256').update(res.body).digest('hex'),
+ legalFormCode: form.code,
+ legalFormVerdict: form.verdict,
+ seatNormalized: seat.settlement || null,
+ seatEntryDate: seat.entryDate,
+ latestOwnEntryDate: latestOwnershipEntryDate(deed),
+ });
+ } catch (err) {
+ console.error(` ${eik}: REFUSED — ${err instanceof Error ? err.message : err}`);
+ unresolved++;
+ consecutive++;
+ continue;
+ }
+ consecutive = 0;
+ }
+
+ if (unresolved > 0) {
+ console.error(`${unresolved} candidate(s) unresolved — the cache is incomplete`);
+ return 1;
+ }
+ return 0;
+ } finally {
+ // The purge step ADR-0033 decision 5 puts „in the same job" — in `finally`, and that placement is
+ // the point. Retention is an obligation about other people's data, not a reward for a clean run,
+ // so it must also happen on the paths that leave early: a 429 (exit 2), a tripped breaker, an
+ // unresolved candidate. Under normal operation it removes nothing, because the monthly refresh
+ // rewrites each row well inside the window; anything it does delete is residue — a company that
+ // left the candidate set, or a refresh that never landed.
+ try {
+ const purged = purgeExpired(db, rawDir, { retentionDays, now: now() });
+ if (purged.rows || purged.files || purged.orphans) {
+ console.log(
+ `purged ${purged.rows} row(s), ${purged.files} raw deed(s), ${purged.orphans} orphan(s) past ${retentionDays}d retention`,
+ );
+ }
+ } catch (e) {
+ // A failed purge must be loud but must not mask the run's own outcome — especially not a 429,
+ // whose exit code is what tells the operator to back off.
+ console.error(`purge failed: ${e.message}`);
+ }
+ db.close();
+ }
+}
+
+// CLI entry. Kept off the import path so the module stays testable.
+if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
+ run()
+ .then((code) => {
+ process.exitCode = code;
+ })
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/tr/fetch-deeds.test.mjs b/scripts/tr/fetch-deeds.test.mjs
new file mode 100644
index 000000000..3a4b1dc01
--- /dev/null
+++ b/scripts/tr/fetch-deeds.test.mjs
@@ -0,0 +1,427 @@
+// node:test — the deed crawler, driven entirely through injected I/O. No network, no real scratch.
+//
+// This is the only component that touches a public register at volume, so what it must NOT do is the
+// substance: never exceed the pace, never retry a 429, never turn a transient wall into permanent
+// data, and never re-request what it already holds. Spec §3.3 permits a bounded per-ЕИК lookup and
+// forbids bulk scraping; the difference between the two is enforced here.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+import {
+ parseTrOptions,
+ run,
+ MIN_INTERVAL_MS,
+ BREAKER_TRIP,
+ TRIES_PER_EIK,
+} from './fetch-deeds.mjs';
+
+const DEED = (uic) => ({
+ uic,
+ fullName: '"АЛФА СТРОЙ" ООД',
+ legalForm: 4,
+ sections: [
+ {
+ subDeeds: [
+ {
+ groups: [
+ {
+ fields: [
+ {
+ nameCode: 'CR_F_19_L',
+ htmlData: ``,
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ {
+ nameCode: 'CR_F_5_L',
+ htmlData: `Населено място: гр. Пловдив
`,
+ fieldEntryNumber: '20110502101008',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
+
+const ok = (uic) => ({
+ status: 200,
+ headers: {},
+ body: Buffer.from(JSON.stringify(DEED(uic)), 'utf8'),
+});
+const status = (s) => ({ status: s, headers: {}, body: Buffer.from('') });
+
+// Two valid ЕИК (real checksums) plus one that is shape-valid but checksum-invalid.
+const A = '201122335';
+const B = '203445566';
+const C = '204556676';
+const BAD_CHECKSUM = '201122336';
+
+function ctx() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-crawl-'));
+ return {
+ dir,
+ dbFile: path.join(dir, 'tr-cache.sqlite'),
+ rawDir: path.join(dir, 'deeds'),
+ cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
+ };
+}
+const eiksFile = (dir, eiks) => {
+ const f = path.join(dir, 'eiks.txt');
+ fs.writeFileSync(f, eiks.join('\n') + '\n');
+ return f;
+};
+
+/** Drive run() with a recording transport. `routes` maps ЕИК → response (or a function of attempt). */
+function harness(c, eiks, routes, extraArgv = []) {
+ const calls = [];
+ const waits = [];
+ const httpGet = async (url) => {
+ const eik = url.split('/').pop();
+ calls.push(eik);
+ const r = routes[eik];
+ return typeof r === 'function' ? r(calls.filter((e) => e === eik).length) : (r ?? status(404));
+ };
+ return {
+ calls,
+ waits,
+ promise: run({
+ httpGet,
+ sleep: async (ms) => void waits.push(ms),
+ now: () => new Date('2026-08-05T12:00:00Z'),
+ guard: () => {},
+ dbFile: c.dbFile,
+ rawDir: c.rawDir,
+ argv: ['node', 'fetch-deeds.mjs', '--eiks-file', eiksFile(c.dir, eiks), ...extraArgv],
+ }),
+ };
+}
+
+const openCacheRO = (dbFile) => new DatabaseSync(dbFile);
+const rows = (dbFile) => {
+ const db = openCacheRO(dbFile);
+ const r = db.prepare('SELECT eik, status, outside_reason FROM deeds ORDER BY eik').all();
+ db.close();
+ return r;
+};
+
+// ── options ───────────────────────────────────────────────────────────────────
+test('parseTrOptions: defaults are the documented polite pace', () => {
+ const o = parseTrOptions(['node', 'x', '--eiks-file', '/tmp/e.txt']);
+ assert.equal(o.eiksFile, '/tmp/e.txt');
+ assert.equal(o.limit, Infinity);
+ assert.equal(o.minIntervalMs, MIN_INTERVAL_MS);
+ assert.ok(MIN_INTERVAL_MS >= 3000, 'the documented pace is 1 request / 3 s');
+});
+
+test('parseTrOptions: rejects a pace FASTER than the documented one', () => {
+ // Tuning around a limiter empirically is precisely what spec §3.3 forbids; make it un-passable.
+ assert.throws(
+ () => parseTrOptions(['node', 'x', '--eiks-file', '/e', '--min-interval-ms', '100']),
+ /min-interval/i,
+ );
+});
+
+test('parseTrOptions: --eiks-file is required, and numeric flags are validated', () => {
+ assert.throws(() => parseTrOptions(['node', 'x']), /eiks-file/i);
+ for (const bad of ['0', '-1', 'abc', '1.5'])
+ assert.throws(
+ () => parseTrOptions(['node', 'x', '--eiks-file', '/e', '--limit', bad]),
+ /limit/i,
+ bad,
+ );
+});
+
+// ── pacing ────────────────────────────────────────────────────────────────────
+test('requests are sequential and spaced by at least the documented interval', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [A, B, C], { [A]: ok(A), [B]: ok(B), [C]: ok(C) });
+ assert.equal(await h.promise, 0);
+ assert.deepEqual(h.calls, [A, B, C], 'sequential, in order — never concurrent');
+ const paces = h.waits.filter((w) => w >= MIN_INTERVAL_MS);
+ assert.ok(paces.length >= 2, `expected a pace wait between requests, got ${h.waits.join(',')}`);
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── 429 ───────────────────────────────────────────────────────────────────────
+test('a 429 ends the run with exit 2 and marks NOTHING', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [A, B, C], { [A]: ok(A), [B]: status(429), [C]: ok(C) });
+ assert.equal(await h.promise, 2, 'a rate-limit block is its own exit code');
+ assert.deepEqual(h.calls, [A, B], 'stops AT the 429 — C is never requested');
+ // B must not be recorded at all: it is unknown, not absent, and certainly not outside the register.
+ assert.deepEqual(
+ rows(c.dbFile).map((r) => r.eik),
+ [A],
+ );
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('after a 429 the run resumes exactly where it stopped', async () => {
+ const c = ctx();
+ try {
+ const first = harness(c, [A, B, C], { [A]: ok(A), [B]: status(429), [C]: ok(C) });
+ assert.equal(await first.promise, 2);
+ const second = harness(c, [A, B, C], { [A]: ok(A), [B]: ok(B), [C]: ok(C) });
+ assert.equal(await second.promise, 0);
+ assert.deepEqual(second.calls, [B, C], 'A is already cached — not re-requested');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── resumability ──────────────────────────────────────────────────────────────
+test('a re-run over a complete cache makes ZERO requests', async () => {
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A, B], { [A]: ok(A), [B]: ok(B) }).promise, 0);
+ const again = harness(c, [A, B], { [A]: ok(A), [B]: ok(B) });
+ assert.equal(await again.promise, 0);
+ assert.deepEqual(again.calls, [], 'nothing to fetch');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── permanence ────────────────────────────────────────────────────────────────
+test('an empty 200 is the register saying „no deed" and is cached as outside-ТР', async () => {
+ // MEASURED against the live API: an ЕИК that is not a търговец (Община София, 000696327) answers
+ // HTTP 200 with a ZERO-BYTE body — not the 404 or HTML #279 §3 predicts. Reproduced twice, with a
+ // real company returning its full deed in the same window, so it is an answer and not an outage.
+ const c = ctx();
+ try {
+ const empty = { status: 200, headers: {}, body: Buffer.alloc(0) };
+ assert.equal(await harness(c, [A], { [A]: empty }).promise, 0);
+ const [row] = rows(c.dbFile);
+ assert.equal(row.status, 'outside_tr');
+ assert.match(row.outside_reason, /empty body/i);
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('an empty body under a 5xx stays TRANSIENT — the status decides, not the emptiness', async () => {
+ // The pair that keeps R6 honest. Both responses have a zero-byte body; only the 200 is an answer.
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A], { [A]: status(503) }).promise, 1);
+ assert.deepEqual(rows(c.dbFile), [], 'a 5xx must never become permanent „outside ТР"');
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a 404 is a DOCUMENTED negative and is cached as outside-ТР', async () => {
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A], { [A]: status(404) }).promise, 0);
+ const [row] = rows(c.dbFile);
+ assert.equal(row.status, 'outside_tr');
+ assert.match(row.outside_reason, /404/);
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a persistent 5xx is TRANSIENT and is never cached as outside-ТР', async () => {
+ // R6: „outside the register" is permanent by intent. Writing it after a transient wall turns a
+ // temporary outage into permanent data that §8 never re-examines.
+ const c = ctx();
+ try {
+ const code = await harness(c, [A], { [A]: status(503) }).promise;
+ assert.equal(code, 1, 'an unresolved ЕИК makes the run incomplete');
+ assert.deepEqual(rows(c.dbFile), [], 'nothing may be recorded from a 5xx');
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a deed whose UIC does not echo the request is REFUSED, not cached', async () => {
+ // R8, at the crawl boundary: if anything rewrote the identifier we would be caching one company's
+ // deed under another company's ЕИК, and every downstream claim about it would name the wrong firm.
+ const c = ctx();
+ try {
+ const code = await harness(c, [A], { [A]: ok('999999999') }).promise;
+ assert.equal(code, 1);
+ assert.deepEqual(rows(c.dbFile), []);
+ assert.deepEqual(fs.existsSync(c.rawDir) ? fs.readdirSync(c.rawDir) : [], []);
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a checksum-invalid ЕИК is skipped without ever being requested', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [BAD_CHECKSUM, A], { [A]: ok(A) });
+ assert.equal(await h.promise, 0);
+ assert.deepEqual(h.calls, [A], 'the invalid code costs the register nothing');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── output ────────────────────────────────────────────────────────────────────
+test('the raw deed is written under the raw dir and the index records only non-PII', async () => {
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A], { [A]: ok(A) }).promise, 0);
+ assert.deepEqual(fs.readdirSync(c.rawDir), [`${A}.json`]);
+ const db = openCacheRO(c.dbFile);
+ const row = db.prepare('SELECT * FROM deeds WHERE eik = ?').get(A);
+ db.close();
+ assert.equal(row.status, 'fetched');
+ assert.equal(row.legal_form_verdict, 'closely_held');
+ assert.equal(row.seat_normalized, 'ПЛОВДИВ');
+ assert.equal(row.body_sha256.length, 64);
+ // The raw file holds the names; the index must not.
+ assert.ok(!JSON.stringify(row).includes('ТЕСТОВ'));
+ assert.ok(fs.readFileSync(path.join(c.rawDir, `${A}.json`), 'utf8').includes('ТЕСТОВ'));
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('--limit bounds a run without marking the remainder as anything', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [A, B, C], { [A]: ok(A), [B]: ok(B), [C]: ok(C) }, ['--limit', '2']);
+ assert.equal(await h.promise, 0, 'a deliberately bounded run is not an incomplete one');
+ assert.deepEqual(h.calls, [A, B]);
+ assert.deepEqual(
+ rows(c.dbFile).map((r) => r.eik),
+ [A, B],
+ );
+ } finally {
+ c.cleanup();
+ }
+});
+
+/**
+ * N genuinely checksum-valid 9-digit ЕИК. Generated, not hand-written: an earlier version of the
+ * breaker test below used made-up codes, of which only 1 in 10 was valid — so the crawler dropped
+ * them all before requesting anything and the assertion passed on zero calls. A test that exercises
+ * nothing is worse than no test (ADR-0027).
+ */
+function validEiks(n) {
+ const control = (p8) => {
+ const d = [...p8].map(Number);
+ let s = d.reduce((a, x, i) => a + x * (i + 1), 0) % 11;
+ if (s === 10) {
+ s = d.reduce((a, x, i) => a + x * (i + 3), 0) % 11;
+ if (s === 10) s = 0;
+ }
+ return s;
+ };
+ const out = [];
+ for (let i = 0; out.length < n; i++) {
+ const p8 = String(20000000 + i);
+ out.push(p8 + control(p8));
+ }
+ return out;
+}
+
+test('the ЕИК generator used by the breaker test really produces valid codes', () => {
+ const eiks = validEiks(15);
+ assert.equal(eiks.length, 15);
+ assert.equal(new Set(eiks).size, 15);
+ // Proves the breaker test below actually reaches the network path rather than being filtered out.
+ assert.deepEqual(
+ eiks.filter((e) => e.length !== 9),
+ [],
+ );
+});
+
+test('the circuit breaker aborts a sustained wall of failures', async () => {
+ // A long run against a broken endpoint must stop hammering, even though each individual 5xx is
+ // „transient". Distinct from the 429 path: that stops instantly and deliberately.
+ const c = ctx();
+ try {
+ const many = validEiks(BREAKER_TRIP + 10);
+ const routes = Object.fromEntries(many.map((e) => [e, status(503)]));
+ const h = harness(c, many, routes);
+ assert.equal(await h.promise, 1);
+
+ const attempted = [...new Set(h.calls)];
+ assert.equal(attempted.length, BREAKER_TRIP, 'the breaker cuts the run at its threshold');
+ assert.ok(attempted.length < many.length, 'and therefore short of the full candidate set');
+ // The number that actually matters is REQUESTS, not candidates: each unresolved ЕИК costs the
+ // full retry budget, so the breaker's real cost is BREAKER_TRIP × TRIES_PER_EIK. Keep that under
+ // the ~50 at which the register was observed to start returning a sustained 429 — otherwise the
+ // safety mechanism is itself what trips the block.
+ assert.equal(h.calls.length, BREAKER_TRIP * TRIES_PER_EIK);
+ assert.ok(
+ h.calls.length <= 25,
+ `a failing run must not spend ${h.calls.length} requests before giving up`,
+ );
+ assert.deepEqual(rows(c.dbFile), [], 'a wall of 5xx records nothing');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── retention (ADR-0033 decision 5: „a purge step in the same job") ───────────
+test('the job purges past-retention deeds, and does so even when a 429 ends the run', async () => {
+ // Retention is an obligation about other people's data, not a reward for a clean run: the paths that
+ // leave early — a 429, a tripped breaker — are exactly the ones where a naive placement after the
+ // fetch loop would skip it and let third-party names sit on disk indefinitely.
+ for (const [label, route] of [
+ ['clean run', ok(A)],
+ ['429 stops the run', status(429)],
+ ]) {
+ const c = ctx();
+ try {
+ // An old deed with its raw file — well past the 35-day window at the harness's fixed clock.
+ fs.mkdirSync(c.rawDir, { recursive: true });
+ fs.writeFileSync(path.join(c.rawDir, `${C}.json`), '{"owner":"ТРЕТО ЛИЦЕ"}');
+ const db = new DatabaseSync(c.dbFile);
+ db.exec(`CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY, status TEXT NOT NULL, http_status INTEGER, fetched_at TEXT NOT NULL,
+ raw_path TEXT, body_sha256 TEXT, legal_form_code INTEGER, legal_form_verdict TEXT,
+ seat_normalized TEXT, seat_entry_date TEXT, latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1, outside_reason TEXT);
+ INSERT INTO deeds (eik,status,fetched_at,raw_path) VALUES ('${C}','fetched','2026-01-01T00:00:00Z','${C}.json');`);
+ db.close();
+
+ await harness(c, [A], { [A]: route }).promise;
+
+ assert.equal(
+ fs.existsSync(path.join(c.rawDir, `${C}.json`)),
+ false,
+ `${label}: the past-retention raw deed must be deleted`,
+ );
+ assert.equal(
+ rows(c.dbFile).some((r) => r.eik === C),
+ false,
+ `${label}: its index row must go with it`,
+ );
+ } finally {
+ c.cleanup();
+ }
+ }
+});
+
+test('the purge leaves in-window deeds alone — it is a privacy rail, not a cache eviction', async () => {
+ // If it evicted live cache, every run would re-request deeds it already holds, which is precisely
+ // the volume against the register the pacing exists to avoid.
+ const c = ctx();
+ try {
+ await harness(c, [A], { [A]: ok(A) }).promise;
+ assert.equal(rows(c.dbFile).length, 1, 'the deed just fetched must survive its own job');
+ assert.equal(fs.existsSync(path.join(c.rawDir, `${A}.json`)), true);
+ } finally {
+ c.cleanup();
+ }
+});
diff --git a/scripts/tr/paths.mjs b/scripts/tr/paths.mjs
new file mode 100644
index 000000000..e325d2934
--- /dev/null
+++ b/scripts/tr/paths.mjs
@@ -0,0 +1,54 @@
+// Paths and path sanitizers for the Търговски регистър leg (issue #279, ADR-0033).
+//
+// Everything this leg writes lives under scratch/tr/, git-ignored, behind the same refuse-to-run rail
+// the CACBG crawl uses — a deed carries third-party personal data (owner and manager names, the
+// company's street address), so it is ADR-0010 decision 6 territory, extended by ADR-0033 to a second
+// source with a stated retention.
+//
+// scratch/tr/deeds/.json raw response, atomic write
+// scratch/tr/tr-cache.sqlite the index — ЕИК, dates, codes, verdicts. NO names.
+//
+// The constants below are only DEFAULTS for the CLI. Every function that touches the filesystem takes
+// its path explicitly, so tests drive temp directories without mutating process state.
+
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { assertScratchIgnored } from '../cacbg/guard.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+export const TR_SCRATCH = path.join(ROOT, 'scratch', 'tr');
+export const TR_RAW = path.join(TR_SCRATCH, 'deeds');
+export const TR_DB = path.join(TR_SCRATCH, 'tr-cache.sqlite');
+
+/** Refuse to run unless scratch/tr is git-ignored. Call before the first fetch. */
+export function assertTrScratchIgnored() {
+ assertScratchIgnored('tr');
+}
+
+// A bare ЕИК: 9 or 13 digits, nothing else. Deliberately NOT `path.basename`-normalised — an ЕИК that
+// needed normalising did not come from where we think it did, and silently repairing it is how you end
+// up fetching a different company's deed.
+const EIK_SHAPE = /^(?:\d{9}|\d{13})$/;
+
+/**
+ * Sanitize an ЕИК before it becomes a path segment or a URL segment.
+ *
+ * Returns the value VERBATIM — as a string, always. Bulgarian public bodies carry codes of exactly the
+ * `000…` shape, so a numeric round-trip anywhere on this path silently rewrites the identifier and the
+ * crawler fetches somebody else's deed (R8).
+ *
+ * Shape only. Whether the code's CHECKSUM is valid is a different question with a different remedy,
+ * answered by eik.mjs — conflating the two would report a real-but-invalid code as a path attack.
+ * @param {unknown} eik @returns {string}
+ */
+export function safeEik(eik) {
+ const s = String(eik ?? '');
+ if (!EIK_SHAPE.test(s)) throw new Error(`unsafe ЕИК: ${JSON.stringify(eik)}`);
+ return s;
+}
+
+/** Absolute path of the cached raw deed for an ЕИК, under `rawDir` (default TR_RAW). */
+export function deedPath(eik, rawDir = TR_RAW) {
+ return path.join(rawDir, `${safeEik(eik)}.json`);
+}
diff --git a/scripts/tr/paths.test.mjs b/scripts/tr/paths.test.mjs
new file mode 100644
index 000000000..3401260d6
--- /dev/null
+++ b/scripts/tr/paths.test.mjs
@@ -0,0 +1,64 @@
+// node:test — path sanitizers and the refuse-to-run rail for the Trade Register leg.
+//
+// The deed cache holds third-party personal data (owner/manager names, company addresses), so the
+// same rail the CACBG crawl runs behind applies here: everything is written under scratch/, and
+// scratch/ must be git-ignored, asserted BEFORE any fetch. ADR-0010 decision 6 as extended by ADR-0033.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { TR_SCRATCH, TR_RAW, TR_DB, safeEik, assertTrScratchIgnored } from './paths.mjs';
+import { assertScratchIgnored } from '../cacbg/guard.mjs';
+
+test('the TR scratch tree sits under scratch/ and is git-ignored in this repo', () => {
+ assert.ok(TR_SCRATCH.split(path.sep).includes('scratch'), TR_SCRATCH);
+ assert.ok(TR_RAW.startsWith(TR_SCRATCH));
+ assert.ok(TR_DB.startsWith(TR_SCRATCH));
+ assert.doesNotThrow(() => assertTrScratchIgnored());
+});
+
+test('the guard is the CACBG one generalized, not a second copy', () => {
+ // A duplicated safety rail drifts from the original. assertScratchIgnored now takes the
+ // subdirectory, and its existing no-argument callers keep working unchanged.
+ assert.doesNotThrow(() => assertScratchIgnored());
+ assert.doesNotThrow(() => assertScratchIgnored('tr'));
+ // .gitignore ignores `scratch/` WHOLESALE, so every subdirectory of it passes — an unknown name is
+ // not a way to reach the failure branch. Escape scratch/ instead (path.join normalises this to
+ // `docs/.probe`, which is tracked) to prove the guard still refuses when the target is not ignored.
+ assert.throws(() => assertScratchIgnored(path.join('..', 'docs')), /REFUSE TO RUN/);
+});
+
+test('safeEik accepts only a bare 9/13-digit code and returns it verbatim', () => {
+ assert.equal(safeEik('115536179'), '115536179');
+ assert.equal(safeEik('1155361790001'), '1155361790001');
+ // Leading zeros survive — public bodies are exactly this shape, and losing them fetches a
+ // DIFFERENT company's deed.
+ assert.equal(safeEik('000696327'), '000696327');
+});
+
+test('safeEik refuses anything that could leave the intended path or URL', () => {
+ for (const bad of [
+ '',
+ null,
+ undefined,
+ '..',
+ '../115536179',
+ '/115536179',
+ '115536179/../x',
+ '115536179?x=1',
+ '115536179#f',
+ '11553617x',
+ '11553617',
+ '1155361790',
+ 'ЕИК 115536179',
+ ' 115536179',
+ '115536179 ',
+ ]) {
+ assert.throws(() => safeEik(bad), /unsafe/i, JSON.stringify(bad));
+ }
+});
+
+test('safeEik does not validate the CHECKSUM — that is a separate question', () => {
+ // Path safety and identity validity are different concerns: a shape-valid but checksum-invalid code
+ // must still be rejectable by the caller with a specific reason, not conflated into „unsafe path".
+ assert.equal(safeEik('115536170'), '115536170');
+});