Skip to content

feat(tr,db,web): регистрово доказателство вместо отличителност на името (#279) - #309

Merged
todorkolev merged 53 commits into
midt-bg:mainfrom
ydimitrof:feat/registry-evidence-links
Aug 14, 2026
Merged

feat(tr,db,web): регистрово доказателство вместо отличителност на името (#279)#309
todorkolev merged 53 commits into
midt-bg:mainfrom
ydimitrof:feat/registry-evidence-links

Conversation

@ydimitrof

Copy link
Copy Markdown
Contributor

Какво и защо

Днес една връзка „длъжностно лице ↔ дружество" се публикува, ако декларираното фирмено наименование е структурно отличително — евристиката B_distinctive. От 101 публикувани връзки 36 почиват единствено на съвпадение по име.

Този PR сменя основанието: връзка се публикува само срещу проверим факт от Търговския регистър (GET portal.registryagency.bg/CR/api/Deeds/{ЕИК}). Измерената повърхност расте до 329 връзки / 311 лица / 239 дружества — тоест 264 връзки, които днес задържаме, стават поименни публични твърдения. Затова прагът за точност се вдига, а не се запазва.

Решенията са записани в ADR-и, не в този текст: ADR-0033 (доказателственият ред, изключението от ADR-0007, барът за акционерни форми, монотонността като гейт), ADR-0034 (каденцията) и ADR-0035 (гейтът за самоличност на дружеството). Заменя ADR-0009/0015/0017.

Свързан issue

Част от #279. Съдържа и поправките по прегледите на PR-а във форка (ydimitrof/sigma#2), включително точки от чеклиста на #279 §5 (§1.1, §1.3, §1.4, §2).

Вид промяна

  • feat — нова функционалност
  • fix — поправка на бъг

Основното: шестстепенният доказателствен ред

Първото съвпадение печели. Какво може да заключи всяко стъпало е целият въпрос — регистърът доказва самоличността на дружеството, никога че лицето притежава дял; това твърдение идва от собствената му декларация.

# Стъпало Ефект
1 Акционерна форма (АД/ЕАД/АДСИЦ/КДА) никога не се показва — книгата на акционерите не е публична
2 Документ — трите имена в живо поле на акта публикува, ако и дружеството е установено (ADR-0035)
3 Потвърдено — деклариран ЕИК или съвпадащо седалище публикува
4 Оборена — липсва, а вписването предхожда периода връзката отпада
5 Неизвестна остава скрита

Барът по т. 1 е обединение от три независими сигнала (декларирана форма, суфикс от акта, числов legalForm); неизвестен код задържа, никога не пропада към публикуване.

Гейтът за самоличност на дружеството (ADR-0035)

Прегледът намери път, по който невярна връзка все още можеше да се публикува, и той заслужава отделно четене.

Стъпало 2 доказва, че някой с тези три имена е вписан в дружеството, което сме извикали — не че това е дружеството, което лицето е декларирало. resolveEntity избира едно ЕИК измежду печелившите по обществени поръчки, а nameGloballyUnique обхожда само участниците, никога регистъра. Затова дружество на длъжностното лице, което никога не е кандидатствало, се резолвва към чужд едноименен печеливш, а съименник в неговия акт „доказва" връзка, невярна и в двете си половини. Две съвпадения, нито едно от които рядко в България.

Сега преди стъпало 2 да твърди, нещо извън фирменото наименование трябва да установи дружеството: деклариран ЕИК, декларирано седалище, съвпадащо с вписаното, или достатъчно отличителна фирма. Всичко останало връща нов, отделен въздържащ се вид document_uncorroborated — никога не публикува, не носи роля и не носи matched_fact, защото да твърди което и да е от двете би изтекло точно това, което стъпалото отказа да каже. Броим го: това е числото, което F8 чете, за да реши дали гейтът да се стегне до строго потвърждаване.

Рамки за клевета

  • Съвпадението е в рамките на ЕДИН запис. Едно поле рутинно държи няколко съдружници; комбиниране на токени между тях е бъгът, който назовава грешен човек. Редът на разбор е тестван инвариант.
  • Само пълно съвпадение и на трите имена (ЗГР чл. 9). Две от три не се приема. Латинска буква прави името несъвпадение, а не фалшиво съвпадение — и се брои.
  • Заличени обстоятелства не могат да произведат съвпадение.
  • matched_fact е ЗАТВОРЕН речник (seat: / role: / eik) — никога име. Одитът налага речника, защото схема не може.
  • Суровите актове не напускат git-ignored scratch/, живеят 35 дни и се четат само за булев резултат. Имена на трети лица не влизат в D1, в изтеглянията или в лога.

Обхождане на регистъра

Не обхождаме масово ТР (спец. §3.3, КЗЛД, CJEU C-200/23). Затворено множество кандидати (~400 вече резолвнати ЕИК), последователно, 1 заявка / 3 s, растящ backoff, 429 никога не се повтаря — спира хода и не маркира нищо. Ехото на UIC се сверява срещу заявеното ЕИК преди запис.

Как е тествано

pnpm lint · pnpm typecheck · pnpm check:docs · pnpm check:coverage   чисто
pnpm test                                     421 db · 492 web · 61 ingest · 45 shared · 20 etl · 10 config
node --test scripts/*.test.mjs                91
node --import ./scripts/cacbg/register-ts.mjs --test scripts/cacbg/*.test.mjs scripts/tr/*.test.mjs   248

Покритието се качва във всеки workspace; ratchet-ът е чист.

Браузърна проверка срещу реален Worker + D1 + SSR/хидратация: 22/22 твърдения (/conflicts, страница на лице, страница на дружество, методология, търсене).

Мутационна проверка на гейтовете, които носят клеветнически риск — тест, който минава при обърнато условие, не е гейт:

  • гейт на стъпало 2, който никога не задържа → падат 4 теста; който винаги задържа → падат 7 (позитивните контроли го държат като граница, не одеяло); без R10 в споделения помощник → падат 2
  • изключена ос B_eik_invalid в одита → пада тестът ѝ
  • разминаване в едно от четирите копия на гейта за печат → падат двата свързващи теста

Бележки към ревюъра

Провал, който само реален пуск даде. Първата версия на миграция 0007 беше учебникарското create-copy-drop-rename. Мина тестовете със sqlite3 CLI и се провали срещу истинско D1: interest_link_evidence сочи към interest_links, D1 налага външни ключове, а PRAGMA defer_foreign_keys не оцелява при изпълнението по едно изречение, което wrangler прави. CLI-тестовете минаха, защото там външните ключове са изключени по подразбиране. Заменено с тригери — същата инварианта за всеки писач, без пренареждане и без загуба на печати. Проверено срещу локално D1: минава чисто, второ прилагане е no-op.

Сливането с main беше текстово чисто и семантично счупено. git merge-tree не докладва конфликт, а четири комплекта тестове паднаха: и двете страни бяха пипали refresh-slice.sql и ship-related-persons.mjs, а новите ви харнеси градят схема до 0003. Поправено в отделен комит, с обяснение защо инжекцията в ship-e2e се мести на листова таблица.

control_hash NOT NULL не е направено както е поискано в §2, и това е нарочно. Полето е наистина опционално в източника — регистърът пропуска <ControlHash> — така че NOT NULL превръща липсващо поле в спиращ зареждането отказ, а измислен запълнител би твърдял проверка за цялост, която никой не е правил. Истинският дефект (UNIQUE (xml_file, control_hash) изобщо не ограничаваше, защото SQLite брои NULL като различни) е поправен с уникален индекс върху (xml_file, folder_year, COALESCE(control_hash,'')).

nanoid. 36cdd9d направи същото вдигане 3.3.16 → 3.3.18 като ваш #291; двете страни се съгласяват за версията, lockfile-ът се слива в едно състояние. Комитът остава в историята, но е без ефект спрямо вашия.

Какво НЕ влиза тук

Чеклист

  • Комитите следват conventional commits и нямат Co-Authored-By: trailer към агент
  • PR-ът е с един логически обхват и е от форк към midt-bg/sigma:main
  • pnpm typecheck минава
  • pnpm test минава
  • pnpm lint е чисто
  • Няма комитнати тайни, .env* или .dev.vars
  • Документацията в docs/ е обновена (3 нови ADR-а, индекс, ръководство за сваляне, методологична страница)

…tier

Records the decision before any code: a link publishes only against a checkable
Trade Register fact (midt-bg#279). If review rejects the grounds, the whole approach
falls — which is why this ADR comes first rather than last.

Seven decisions, each separately contestable: the evidence ladder (six outcomes,
first match wins); a heuristic that grounds an assertion — the explicit exception
to ADR-0007 decisions 2 and 3, together with what justifies it (corroboration
against the official's own filing, never a standalone name match) and the filters
that can only withhold; the joint-stock bar as a union of three signals, where an
unknown legal-form code withholds; reconciliation of a terminated stake against
the live deed, reversing ADR-0021 E11 (the "и към днешна дата" labels defer to
phase 2); the deed cache, extending ADR-0010 decision 6 with a 35-day retention;
monotonicity as a gate rather than a store — §8 of the issue is false as written;
and the launch gates.

Supersedes ADR-0009, ADR-0015, ADR-0017 (headers and index synchronised).
Amends ADR-0007, ADR-0010, ADR-0021 (E11 only), ADR-0028 — accepted ADRs are not
rewritten, so the amendment is recorded here.

Cross-checked against issue midt-bg#279 and the earlier spike; carries the three facts
the issue does not: the sustained 429 at ~50 requests, the open question of the
ЕАД legal-form code, and the deliberate non-use of the beneficial-ownership
fields (C-37/20).
The index has marked it superseded by ADR-0030 since PR midt-bg#226, but the file header
still read "Accepted". A header/index divergence, spotted while synchronising the
index for ADR-0033.
/conflicts rendered an empty surface locally — seed.sql carried only authorities,
tenders and bidders, so the свързани-лица routes had nothing to show and the
feature could not be verified in a browser at all.

Adds two invented officials, three invented companies and the rows the read gate
actually requires (packages/db/src/queries/related-persons.ts): published status
with a surfaced ownership class, a live contract for the ЕИК, and signed_at
inside the declared window so the „в периода" split is exercised. One link per
outcome — a self stake, a family stake, and a joint-stock link left held — so a
change to the publishing rule shows up as a before/after rather than as an empty
page either way.

Every person and company is invented; no real declarant, no real winner. The ЕИК
pass the real 9-digit checksum (normalize-raw.sql's eik_valid), so the fixture
cannot teach a wrong ЕИК shape.
…capability

Foundations for the Trade Register evidence leg (midt-bg#279, ADR-0033). No crawling and
no publishing decision yet — this is the transport, the cache and the identifier
rule, each with the refusals that make the later steps safe.

scripts/tr/eik.mjs — the ЕИК checksum, which until now was callable only from SQL
(normalize-raw.sql's eik_valid). The crawler has to decide in Node whether a code
is worth a lookup. A test lifts the CASE expression straight out of the .sql file
and runs both implementations over ~120 values through node:sqlite, so the twin
cannot drift from the rule it mirrors.

scripts/tr/client.mjs — 429 is never retried. The register's block is sustained
(an earlier spike saw 429 at ~50 requests and then 429 to everything, with no
Retry-After and no quota header), so it is an instruction to stop, not a
transient. 5xx and network faults retry with growing backoff; a 429 arriving
mid-retry aborts rather than letting a later 200 mask the block. A foreign host
is refused before any packet is sent.

Two transport decisions worth stating: this leg does NOT reuse cacbg/tls.mjs —
that module pins register.cacbg.bg's leaf because that host serves a broken chain
(ADR-0011), whereas this host verifies against system roots, which is stronger.
And it uses node:https, not fetch: measured live, the identical request returns
500/empty via undici and 200 with the full 34,398-byte deed via node:https.

scripts/tr/cache.mjs — resumability plus the PII rail. The index holds no name at
all: ЕИК, dates, codes, verdicts and a body hash, never an excerpt. Any value
carrying a ten-digit run is refused outright — the ЕГН shape, and sound as a
check precisely because an ЕИК is 9 or 13 digits, never 10.

scripts/tr/*.test.mjs matched NO CI job before this commit; the workflow globbed
only scripts/ and scripts/cacbg/. Fixed here rather than later, or every test
above would have been decorative.

cacbg/guard.mjs's assertScratchIgnored takes the subdirectory instead of being
copied — a duplicated safety rail drifts from its original. Existing callers are
unchanged.
The libel-critical part of midt-bg#279. Pure and offline: deed in, verdict out. Nothing
is wired into the publishing path yet — that is the next commit.

deed.mjs enforces one order of operations, and it is an invariant with tests on
it rather than a comment: decode entities → split on record-container/hr--report
→ drop erased → strip tags within the entity → match tokens within ONE entity.
Field CR_F_19_L routinely holds several съдружници in a single string; matching
against the whole field lets one person's given name combine with another's
surname, and the output is a named public claim about the wrong human being. The
test proves both directions — the frankenstein name does NOT match, the correct
declarant DOES, and the naive whole-field approach WOULD have fired. Without that
last assertion a matcher that always returns false would pass the suite.

evidence.mjs implements the ladder: bar_joint_stock · document · confirmed ·
refuted · unknown · outside_tr, first match wins. What it establishes is company
IDENTITY, never that the official owns anything — the ownership claim is the
official's own filed declaration. So the failure mode of a wrong match is a real
official attached to the wrong company's contracts.

Fixtures are real markup, copied from the live deed for ЕИК 115536179. Writing
this against imagined markup is how the entity-boundary bug ships, and doing so
caught two things the ADR had wrong.

ADR-0033 corrected on implementation, both from re-measuring the full deed:
  • the erasure marker is `erasure-text-inline`, NOT `field-text--erased` — that
    class occurs zero times, and an erased container has no field-text paragraph
    at all. Both spellings are now honoured; assuming one costs a wrong publish.
  • fieldOperation is not the erasure signal (2 on erased fields here, 1 on the
    erased history records W0 sampled), so it is not relied on.

Guards that can only withhold: an unknown legal form never falls through to a
lower rung; a two-token declarant can never earn „Документ"; a Latin homoglyph is
a non-match and is counted; an empty seat never confirms; a seat registered after
the declared period does not confirm; refutation never applies to a family stake
and is suppressed inside the 2011-2012 re-registration window; matched_fact is a
closed vocabulary that cannot carry a name.
One request per candidate ЕИК, sequential, 1 per 3 s. What it refuses to do is
the substance, and every refusal is driven offline through injected I/O.

  • The pace flag cannot make it faster, only slower. Tuning around a rate
    limiter empirically is what spec §3.3's „NEVER bulk-scrape" forbids, so
    --min-interval-ms below the documented 3 s is rejected outright.
  • A 429 ends the run with exit 2 and records NOTHING about the ЕИК that hit
    it — that ЕИК is unknown, not absent, and the throttle is a fact about us,
    not about the company. A later re-run resumes exactly there.
  • The candidate set is closed: it never follows a link out of a deed.
  • A checksum-invalid code is dropped before any request.
  • The returned deed's UIC must echo the request, or it is refused and not
    cached — otherwise one company's deed lands under another's ЕИК.

Two things measured against the live register during this work:

„Outside the Търговски регистър" does NOT look like midt-bg#279 §3 describes. An ЕИК
that is not a търговец answers HTTP 200 with a ZERO-BYTE body — not a 404, not
HTML. Verified on Община София (000696327): empty on two consecutive requests
while a real company returned its full deed in the same window. The rule that
keeps R6 honest is therefore the STATUS, not the emptiness: empty under 200 is
the register's answer and may be cached permanently; empty under 5xx is a
failure and stays transient. Both directions are tested. Without this the ~4
non-търговец ЕИК would never resolve and the run could never exit 0.

The breaker threshold is 5, not 10, because the unit is ЕИК and each unresolved
one costs the full 5-attempt budget. At 10 the breaker would spend ~50 requests
against an already-failing endpoint — the exact volume at which the register was
observed to begin a sustained 429, i.e. the safety mechanism would itself have
tripped the block. At 5 the worst case is ~25.

The breaker test caught itself being fake: it had been generating made-up ЕИК of
which only 1 in 10 passed the checksum, so the crawler dropped them all and the
assertion passed on zero requests. It now generates genuinely valid codes and
asserts the REQUEST budget, not just the candidate count.
A side table, not columns on interest_links, for three reasons that are easy to
forget once the code is written: SQLite's ADD COLUMN has no IF NOT EXISTS and
these migrations are applied by a bare `d1 execute --file` with no ledger, so a
re-apply must be a no-op; load.mjs rebuilds the CACBG tables from 0003 alone, so
any column added here would have to be duplicated into 0003 and kept in step
forever; and §8 describes the seal as an attached artefact of a link.

A seal is written for EVERY link, not only published ones — the seals on held and
withdrawn links are what make the review queue reviewable.

matched_fact is a closed vocabulary ('seat:<CITY>' | 'role:owner:<FIELD>' |
'role:manager:<FIELD>' | 'eik') and must never carry the matched name; the deed's
names are read only to produce a boolean and never leave git-ignored scratch. A
schema cannot enforce that, so the audit will.

Wired everywhere the 0003 table list already is, and the two FK orderings are
opposite by necessity: the seal ships AFTER interest_links and is wiped BEFORE
it. D1 enforces foreign keys, so getting either backwards breaks the re-seed.

  • ship-related-persons.mjs — TABLES and WIPE_ORDER, with a test asserting both
    orderings and a third asserting every shipped table is also wiped (a table
    that ships but is never wiped accumulates seals for links that no longer
    exist).
  • related-persons.ts CONFLICT_TABLES — so an environment without 0006 degrades
    to an empty surface rather than a 500.
  • load.mjs — applies 0003 AND 0006, and drops the seal first.
  • BOTH related-persons-data.yml and deploy.yml hardcode migration filenames;
    each needed its own step. Missing either leaves a D1 without the table.

ship-reseed.test.mjs builds its fixture from the migrations, so 0006 joins it —
the new table is part of the FK graph that test exists to police.
КДА — командитно дружество с акции — issues shares like an АД, so the same two
reasons apply: the shareholder book is not public, making a declared parcel
unverifiable, and the parcel may be immaterial. It was absent from both
JOINT_STOCK and FORM_TOKENS, so a КДА read as CLOSELY HELD and its declared
holder could reach the public ownership surface as an owner — the exact class of
claim the exclusion exists to prevent.

Two independent effects, hence both lists: JOINT_STOCK is the materiality bar,
and FORM_TOKENS feeds the content-word count in nameDistinctiveness, where a form
token miscounted as a content word inflates distinctiveness toward publishing.

Found while implementing midt-bg#279, whose rung 1 names КДА explicitly alongside АД and
ЕАД, but the gap predates it and is a bug on its own — hence a separate commit.
The mirror cases stay correct: „КДА-ТРЕЙД ЕООД" and „КДА ГРУП ООД" are ООД.
The decision swap (midt-bg#279, ADR-0033). load.mjs no longer asks whether a declared
name looks distinctive; it asks what the Trade Register says about that ЕИК.
publishTier() and seatConfirmed() are deleted — one production caller each —
and publish_tier now carries the evidence kind.

FAIL CLOSED, twice. A missing cache refuses the load outright. A PARTIAL cache
refuses it too, and that is the one that matters: it does not fail loudly
downstream. An 80%-restored cache yields ~80 published links, which clears the
ship floor of 50, ships a decimated surface and wipes the rest of the live
links. --allow-partial-tr is the deliberate override, and an uncached ЕИК under
it is „unknown" (held), never a reason to publish. The workflow now passes
--min-links 250 explicitly rather than relying on the default of 50.

tr-census.mjs is deleted. Its promote() moved C_hold → published on pure name
uniqueness — identity by name coincidence, which is what this change abolishes.

Two design errors the fixtures caught, both about how far ADR-0017's gate reaches:

  • Gating the declared-ЕИК leg on name uniqueness would discard the strongest
    identifier we have exactly where the name is useless — a фирма backing two
    ЕИК is the case ADR-0028 was written for. Now ungated.
  • Gating the seat leg on nameDistinctiveness would empty the rung of its
    purpose: rescuing a GENERIC name is what the seat is for. Only national
    non-uniqueness blocks it.

A consequence worth stating plainly: midt-bg#279 NARROWS the family surface. The
registered owner of a family stake is the relative, whose name we deliberately
never store, so rung 2 („Документ") can never fire for a family link by
construction — its identity can only be confirmed by something the OFFICIAL
declared, the seat or the ЕИК. ADR-0032's decision is untouched (family
publishes on the named surface exactly like self); it now needs the same
evidence as everything else. The fixture's seat-less family case is therefore
held, and a seat-carrying one was added as the positive control, without which
„family published: 0" would be indistinguishable from a dead path.

Fixture declarants gained patronymics. Bulgarian names are three-part by statute
(ЗГР чл. 9) and rung 2 requires the full triple, so two-token fixture names were
not merely unrealistic — they could never earn „Документ" and were quietly
testing the wrong path.

A seal is written for every link, held and withdrawn included, so the review
queue can explain itself; live_status is re-derived each run and never sealed.
load.mjs also emits candidate-eiks.txt — every resolved ЕИК, not just published
ones — which is the crawler's closed input set.
The read path, the DTO, the card and the audit (midt-bg#279, ADR-0033). This is what
makes „every shown link explains itself" true rather than a promise: a reader
sees which act was read, which entry, and when — and can open the same act.

THE PREDICATE HAS THREE COPIES and all three moved together. SURFACED_OWNERSHIP
in related-persons.ts is the one people find; the other two are precompute.sql
and refresh-slice.sql, and the second of those runs on the 6-hourly cron feeding
the officials search index. Missing it would leave officials findable whose links
no longer surface — a stale index that contradicts the page it links to.

The card's wording is load-bearing and tested as such. „лицето е вписано като
съдружник/собственик" reports what the act RECORDS; it does not say the official
owns anything, because that claim comes from their own declaration and is
rendered separately as „дялово участие". „Потвърдено" says the COMPANY was
identified from something the official declared — nobody was found in the act —
so its label must not imply anyone was, and a test asserts it never contains
„вписан" or „собственик".

audit.mjs's C axis was dead code the moment the loader stopped emitting
B_distinctive: it re-derived name distinctiveness, a rule no longer in force. It
now re-derives the rule that IS in force — a published link must carry a seal,
the seal must be a publishing rung, and the tier must agree with it — plus the
PII rail as an audited invariant rather than a convention: matched_fact is a
closed vocabulary, so a name reaching it is a hard finding. Five new cases,
including a positive control, without which all four negatives would still pass
if the axes fired unconditionally.

Eight test fixtures needed 0006 or evidence seals. That is not incidental: the
read gate now requires a seal, so a fixture without one renders an EMPTY surface
and every assertion below it passes vacuously. related-persons-sql.test.ts seals
its links with a SELECT over interest_links, so a row added later is sealed
automatically and cannot silently drop off the surface.

Two things the browser check caught that no unit test could. The dev server was
serving pre-change HTML because my local vite config pinned __SIGMA_DEPLOY_TAG__
to a constant — the worker prefixes that tag into its edge-cache key, so the key
never rotated and stale HTML survived every restart. That is exactly the failure
the real vite.config.ts warns about, reproduced by ignoring it. And the row's
<dt> is CSS-uppercased, so the assertion had to be case-insensitive.
midt-bg#279 §9 asks for the publishing decisions to run on a schedule and for the rule
to be public. ADR-0021 E10 makes the second one a libel-defence obligation, not
a nicety: a reader must be able to re-derive any link.

The methodology page now carries the evidence ladder VERBATIM — all six rungs,
including the two that hide a link — and, more importantly, the disclosures that
make it honest rather than merely stated:

  • the register carries NO ЕГН, so a three-name match is not proof of identity
    and a namesake is possible. Said in those words, not implied.
  • which is why the match must be all three names inside ONE registry entry;
    initials and Latin-script names are refused.
  • seats move, so a registered seat only confirms a period it predates.
  • erased entries are skipped everywhere.
  • what the evidence proves and what it does not: the register confirms the
    IDENTITY OF THE COMPANY; it does not certify that the official owns it —
    that claim is their own declaration. This is why the card says „вписано
    като съдружник/собственик" and never „собственик според ТР".
  • the monthly cadence, the entry number and lookup date on every link, and the
    35-day retention with no third-party name kept.

The divestment promise is corrected too: withdrawal on a later silent filing is
an INFERENCE FROM SILENCE whose commonest cause is a finished mandate, so it is
now reconciled against the live act before it takes effect.

Cadence, deliberately on the EXISTING workflow rather than a second one. A
duplicate would carry its own copy of the credential guards, the D1-target guard
and the ship floor — and the copy is the one nobody exercises. A scheduled run
takes the same path, targeting STAGING (production stays manual, so a prod write
remains a deliberate act) and with full_crawl false, so it never touches the
register: decisions are a pure function of declarations, cached deeds and
contracts, which is exactly what §9 means.

Registry lookups get their own monthly workflow because they are the only step
with a network budget, and separating them is what keeps the decision run
zero-network by construction. It ships nothing, caches the deeds between runs so
a refresh stays ~400 requests rather than a full crawl, and treats exit 2 (the
rate limiter) as a WARNING, not a failure — the run stopped politely and the
cache is resumable, and a red X there would train everyone to ignore the one
signal that means „the register asked us to stop". The uploaded artifact is the
cache INDEX only; the raw deeds carry third-party names and never leave the runner.

Spec §3.3's Phase-0 is closed with what was measured, including the three facts
that contradicted the issue: the sustained 429, „not in the register" being an
HTTP 200 with an empty body, and fetch failing where node:https succeeds.

The LIA gains an addendum for the new source and the runbook two new takedown
grounds — a court-annulled entry (чл. 29 ЗТРРЮЛНЦ) and a namesake match. Neither
looks like a bug: the pipeline works correctly and the claim is still wrong, so
neither is fixed by a rules bump.
CI caught both; local runs could not, and the reasons are worth recording because
they will recur.

load-ambiguous.test.mjs ran load.mjs WITHOUT setting TR_CACHE_DB, so it fell back
to the repo's real scratch/tr/tr-cache.sqlite — a file that exists on my machine
only because the live smoke run created it, and that exists nowhere in CI. The
test was silently running against a developer artifact. It now builds its own
empty cache and passes the path explicitly, like every other loader test.

refresh-officials.test.ts executes the real refresh-slice.sql, which now joins
interest_link_evidence, but built its schema from 0000/0001/0003 only. Locally
`pnpm test` reported 6/6 because turbo REPLAYED A CACHED RESULT for @sigma/ingest
— my change was to scripts/refresh-slice.sql, which is not among that package's
declared inputs, so the cache key did not move. `turbo run test --force` fails
the same way CI does. Fixture now applies 0006 and seals its links.

The seal is derived with a SELECT over interest_links rather than written row by
row, so a link added to the fixture later is sealed automatically. That matters
more than it looks: the officials batch requires a publishing rung, so an unsealed
fixture indexes NOBODY and every assertion below it passes vacuously.

Coverage baseline ratcheted up, as check-coverage nudged: apps/web +1.33pp
(the new jsdom card tests and the evidence-label tests), packages/db +0.34pp,
packages/ingest +0.57pp, packages/shared +0.83pp branches.
The entity split and the erasure strip both matched class='...' only. The live
register emits single quotes today and every fixture is verbatim from a real
deed, so nothing is broken now — but the failure mode if that ever changes is
the worst one this module has: the split stops firing, all owners in a field
collapse into one token pool, and a name assembled from two different people
starts matching. That is a named public claim about someone who is not there.

deed.mjs's own doctrine is "refuse loudly, never guess". Silently mis-splitting
is neither, so match either quote style instead.

The new split test strips the <hr> separators first. With them present it passes
on the <hr> rule alone and proves nothing about the quote handling — verified by
watching it pass before the fix.
assertNoEgnShape refused any value matching /\d{10}/, justified in three places
by "an ЕИК is 9 or 13 digits, never 10 — so it cannot reject a legitimate
identifier". That reasoning only holds if the run is matched as a WHOLE. A
13-digit ЕИК (клон) CONTAINS ten-digit substrings, and on the fetched path
rawPath is `<eik>.json`, derived from that very ЕИК:

  /\d{10}/.test("1155361790001.json") === true

upsertDeed sits past fetch-deeds' JSON.parse/assertUicEcho try-catch, so the
throw propagated and killed the whole run on the first branch office that
returned a deed — after its paced request was spent. Fail-closed for
correctness, but the crawl is the data source for the entire feature.

The existing test asserted this exact case was safe and passed anyway:
markOutsideTr sets no rawPath, and the fetched fixture used a 9-digit ЕИК, so
the one path that throws was never exercised.

Anchor the pattern, and screen by EXCLUSION rather than a hand-maintained list
of four field names — upsertDeed binds thirteen values and the next one added
would have bypassed the rail silently.

Two fields stay exempt for structural reasons, not convenience. `eik` is
already shape-validated by safeEik. `bodySha256` is 64 chars of [0-9a-f]:
measured over 200k digests, a standalone ten-digit run occurs in 7.2% of them
(18.1% unanchored), so screening it would refuse roughly one deed in fourteen
for no privacy gain. A digest is not personal data — it exists precisely so
that no deed content reaches the index.
ADR-0033 decision 5 promises the deed cache is kept "with a 35-day retention
(one refresh cycle plus slack) and a purge step in the same job". Neither
existed. --max-age-days defaulted to null and only made a cached row pending
again — it re-REQUESTS, it never deletes — so nothing ever unlinked the raw
JSON that holds co-owner and manager names and company addresses. Third-party
personal data accumulated on disk unbounded, with a documented TTL that deleted
nothing.

Freshness and retention are now separate flags because they are separate
obligations: --max-age-days decides what gets re-requested, --retention-days
decides what stops being kept. Retention defaults to the ADR's 35 days rather
than to "off", so the rail holds for an operator who passes neither.

purgeExpired runs 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. A failed purge is loud but never masks the run's own
exit code, least of all the 429 that tells the operator to back off.

It is a privacy rail, not cache eviction. Under normal operation it removes
nothing, because the monthly refresh rewrites each row well inside the window;
what it actually catches is residue — a company that dropped out of the
candidate set, or a refresh that failed — plus orphaned raw files that no read
path can reach and that are therefore pure retained personal data.

purgeExpired was written before its tests, so RED was established afterwards by
mutation: stubbing the expiry query kills 4 tests, disabling the orphan sweep
kills 2, and moving the call out of `finally` kills the 429 case.
…fies

rules_version was only ever written (load.mjs) and never read for a comparison.
Every "§8's monotonicity gate keys on this" comment — in load.mjs, evidence.mjs
and migration 0006 — pointed at code that did not exist: no pre-wipe export, no
snapshot compare, no finding.

ship-related-persons.mjs's count floor cannot stand in for it. It compares a
COUNT, so a one-for-one swap — one true published link silently dropped, one
gained — leaves it perfectly quiet.

Two halves:

load.mjs exports the currently published set with each link's rules_version
immediately before it drops the CACBG tables. The rebuild is total, so that set
exists only in this instant. On a first run the tables are absent and the export
is an empty array — written rather than skipped, so a missing file keeps meaning
"the loader never ran", not "nothing was published". Any error other than "no
such table" propagates; swallowing it would turn a broken export into a
permanently silent gate.

audit.mjs compares against it. A link published last run and not published now
under an UNCHANGED rules_version is a hard finding: nothing licensed the removal.
Under a changed version it is an intentional event and degrades to a printed
diff, so a rules bump still shows a human which named claims it withdrew. Held
and withdrawn links are excluded from the export — they were never a public
claim, so their absence is not a regression.

The failure direction this protects against is recall, not libel: a missing gate
risks withholding a true link, never fabricating a false one. But the surface it
guards is about to triple, so a rules-neutral change must not be able to shrink
it silently.

Also drops the dead nameDistinctiveness import from load.mjs (referenced only in
a comment since the ladder replaced it) and replaces it with a note on why it
stays in classify.mjs.
Superseded ADRs gain a "Superseded by" back-pointer; amended ones gained
nothing. A reader of ADR-0007 — the foundational certainty bar — had no way to
discover that ADR-0033 carved an explicit exception to its decisions 2 and 3.

Adds an "Amended by" line to 0007, 0010, 0021 and 0028, each naming which
decisions moved. A header breadcrumb, so the convention that accepted ADRs are
not rewritten still holds.
expect(text()).toContain('—') ran against the whole page, where the value and
date cells render em-dashes of their own. It would have survived the source
branch being deleted outright. Scope it to the card whose sourceUrl is null and
assert the declaration anchor is absent from it.
It was parked as "resolve with the author". It does not need the author — the
issue's own numbers determine it.

§5 gives each rung a control count: 4 barred + 281 document + 102 seat + 3 ЕИК
+ 21 refuted + 156 unknown = 567, plus the 4 links whose ЕИК is not in the
register at all (§5 scopes the ladder to links "с изтеглен акт", so those reach
no rung) = 571. §10's identity row is 281/102/156/21/4/4 = 568. The difference
is exactly the "3 по ЕИК".

The labels give the cause away. The identity row calls its second bucket
"седалище" — one leg of rung 3 — while the evidence row two lines below calls
the same rung "потвърдено". Rung 3 has two legs; the row tallied one and
presented the result as a partition of the resolved set. A histogram that
dropped a category, not a disputed measurement.

The stated total 568 equals that row's sum exactly, so it is derived from the
histogram rather than measured independently. Both therefore move together:
the bucket becomes "потвърдено: 105", the resolved total becomes 571. F8
measures against those and reports disagreement rather than adopting whichever
reading makes the reconciliation pass.

The identified-vs-surfaced gap moves with it — 57 links on the corrected
reading, 54 on the row as written — so that figure independently distinguishes
the two, which is worth more than the correction itself.

Also records what is NOT a discrepancy, so nobody "fixes" it: "извън ТР" is 4
in §10 and 3 in §3/§11 because the units differ — 3 ЕИК not in the register,
carrying 4 links between them. §10's row counts links.
String.fromCodePoint throws RangeError above U+10FFFF, and the argument comes
straight off the wire: `&#999999999999;` in any deed field threw out of
decodeEntities, out of entityBlocks and registrySeat, past the crawl loop's
refuse-and-continue block — which covered only JSON.parse and assertUicEcho —
and out of run(). One malformed escape ended a paced crawl that had already
spent its request budget, and did the same to load.mjs at decision time.

An out-of-range code point is not a character and cannot be part of a name, so
it now decodes to nothing and the rest of the entity still parses. The in-range
path is unchanged and tested, so the guard bounds the decoder without disabling
it.

Widen the two refuse-and-continue blocks to match what they already claimed:
the crawl loop now wraps the HTML parsing as well as the JSON, and load.mjs
holds the single link whose deed it cannot read rather than failing the whole
run — one bad payload out of ~400 must not decide every other link's fate and
then trip the ship floor.
Deleting the whole `EXISTS (… interest_link_evidence …)` clause out of
SURFACED_OWNERSHIP left the db suite green at 361/361. Every fixture sealed
every link 'document', so the one piece of SQL standing between a withheld link
and a named public claim — that a specific official owns a specific company —
had no test on it at all.

Seed each real withholding rung evidence.mjs can emit (refuted, bar_joint_stock,
unknown, outside_tr) plus a link with no seal row, all on the same winner and
otherwise identical, and assert absence across all four public queries: the
leaderboard, the official page, the company page and the contract drill-down.

The 'confirmed' positive control is the load-bearing part. Without it an absent
row could be absent for an unrelated reason and every assertion would pass
vacuously — which is the failure mode being fixed, not a shape to reproduce.

Verified by mutation: with the gate removed, six of these fail and the positive
control still passes.
toLink read `evidence_kind === 'confirmed' ? 'confirmed' : 'document'`, so
NULL, 'refuted', 'bar_joint_stock', 'outside_tr', a rung added by a later
rules_version, or a typo all became 'document' — the strongest claim the card
can make, rendering „лицето е вписано като съдружник/собственик": that the
register names this specific person in this specific company.

The SQL gate makes that unreachable today, but the failure direction was
backwards at the one mapping in the codebase where a wrong default is a
defamatory statement about a named human being rather than a rendering glitch.
The LEFT JOIN in LINK_SELECT was deliberately left non-inner so a contradiction
would surface here; the fallback then converted exactly that contradiction into
the strongest available label.

Rows are now filtered to the two publishing rungs before mapping, and filtered
before the emptiness check so an official whose every link is withheld 404s
rather than rendering an empty page under their name. One withheld row no
longer affects its sealed siblings.
The closed-vocabulary rail admitted exactly what it exists to catch. `seat:` is
a legitimate prefix and the settlement pattern was unbounded, so
`seat:ИВАН ПЕТРОВ ГЕОРГИЕВ` — a full three-part Bulgarian name (ЗГР чл. 9)
wearing an allowed prefix — passed the audit. That is the value a mis-split of
the seat field would produce, which is the leak the rail was written for.

A Bulgarian settlement is one or two tokens („СОФИЯ", „ВЕЛИКО ТЪРНОВО",
„ГЕНЕРАЛ ТОШЕВО"); a three-part name is exactly three. Bounding at two
separates them, and a rarer three-token seat stops the run for a human rather
than publishing — the right direction for a rail whose failure mode is putting
somebody's name on a served column.

Moved to evidence.mjs next to the code that writes matched_fact, so the gate
and the writer cannot drift apart, and enforced at write time as well: the
audit runs after the whole domain is built, by which point the name is already
in a table.
The seed exists for one reason — without it `/conflicts` renders an empty
surface and the feature is unverifiable in a browser. Then midt-bg#279 added the
evidence seal requirement to SURFACED_OWNERSHIP, the seed wrote no seals, and
the fixture stopped surfacing: this PR's own dev fixture died under this PR's
own gate.

Seal each seeded link with the rung it represents — document for the official's
own stake, confirmed for the relative's seat-proved link, bar_joint_stock for
the withheld АД — so the local surface shows the same three outcomes as
production and keeps its negative case.

The new test runs the real migrations, the real seed and the real exported
queries, because the seed is a promise about the read gate and nothing was
checking it. A future change to the publishing rule that the seed does not keep
up with now fails here instead of in a browser days later.
…cking

Two halves of the same exposure, on a paced crawl where a wasted request
budget cannot be re-spent cheaply.

The request timeout bounded how long a response may STALL, but nothing bounded
how much it may SEND: the reader buffered whatever arrived, so the endpoint
decided how much memory this process held. Cap a body at 8 MB — ~240× the
34 KB a measured deed occupies, so it bounds abuse and can never refuse a
large-but-legitimate company — and destroy the request rather than reject and
leave the socket draining. Split out as collectBody so it is testable without
TLS or a live socket.

The erasure-notice strip used an unbounded lazy `.*?`, which backtracks
quadratically when the opening div is never closed: 34K→3.3ms, 68K→13.6ms,
136K→53.8ms, 272K→240ms, 1M→4.0s, ×4 per doubling. Under an 8 MB cap alone that
is still minutes of remote-controlled CPU per deed, so the cap is not on its own
a fix. Bounding the lazy run to 2000 characters — ~80× a real one-sentence
notice — makes it linear: the same inputs measure 5.3 / 10.3 / 24.8 / 41.6 /
187ms.

The bound cannot resurrect an owner: `erased` is decided independently by
ERASED_MARKER, so an over-long notice still marks the block erased and
liveFields still drops it.
…ck rule

load.mjs joined the cache's stored raw_path straight onto the raw directory —
the one read that did not go through the safeEik rail purgeExpired already
uses. The cache index travels between runs, and once it does a stored path is
attacker-influenced input on a filesystem root. Re-derive from the ЕИК through
deedPath, which also throws on a malformed code rather than quietly reading
another company's deed.

JOINT_SUFFIX in the TR parser and JOINT_STOCK in the classifier are the same
rule held in two places, because the TR parser cannot import out of
scripts/cacbg/ without closing a cacbg↔tr cycle. That duplication has already
drifted once — 5f64f5c added КДА to classify.mjs while deed.mjs's comment still
asserted it was absent there, so the comment was simply false. Correct the
comment and pin the two byte-identical, textually and behaviourally: an
unenforced „keep these in step" note is how the drift happened in the first
place. Verified by mutation — removing КДА from either side fails the test.
…rawl

The register crawl and the decision run have to share a job: the raw deeds hold
third-party names and cannot travel between runners. But the crawl's input list
is derived from the resolved corpus, so only load.mjs can produce it — and
load.mjs refuses to run without the very cache that list is used to fill.

--emit-candidates writes the list and exits 0. Deriving it by running the full
load and ignoring a non-zero exit was the alternative, and it erases the
difference between „no cache yet" and „this run is broken".

It runs on a throwaway copy of the work DB, and that is correctness rather than
tidiness. Reaching the candidate list means rebuilding the corpus tables, which
drops interest_links, and the pass never publishes — so against the real DB it
would leave the table empty. The next real run would then export an EMPTY
prior-published set as its monotonicity snapshot, and the gate whose only job is
to notice a published claim disappearing would pass unconditionally from then
on. Verified by mutation: pointing the pass at the real DB fails both tests.
The two workflows deadlocked and both were permanently green no-ops. The crawl
job gated on scratch/cacbg/staging/candidate-eiks.txt, which only the decision
job writes and nothing persisted, so its check was false on every run forever.
The decision job restored only scratch/cacbg/raw, never scratch/tr, so it hit
`REFUSE TO LOAD: no Trade Register cache` on every run. Each waited on the other
half of a handoff neither performed.

They cannot be joined by persisting the deeds. The crawl's `path: scratch/tr`
already cached deeds/*.json — the co-owner and manager names ADR-0033 decision 5
promises a 35-day retention for. purgeExpired runs on the runner, but older
cache entries live on GitHub's storage under the restore-keys chain, where
eviction is a capacity policy and not retention at all. So the deeds stay on one
runner: crawl and decide in one job, and delete them explicitly at the end.

The cadence follows from that. A decision needs the raw deeds — evidenceVerdict's
strongest rung matches the declarant's name against the register's own text — so
every run that decides must also crawl, and a daily decision would mean ~400
daily requests against somebody else's register. The schedule is therefore
monthly, not daily as §9 and ADR-0033 describe. Restoring a daily decision needs
the crawl to emit per-(link, ЕИК) verdicts so only booleans cross a run boundary;
that is a design change and is deliberately not folded in here.

Also export interest_links + interest_link_evidence in the hydrate step. Not for
the resolver — load.mjs rebuilds them — but for the monotonicity gate: the work
DB had no interest_links at all, so the pre-wipe export read "no such table",
the snapshot was [] every run, and the gate added in 2327e62 protected nothing.
`as const` narrowed the WITHHELD tuples to literal unions, so appending the
no-seal and positive-control rows failed typecheck.
The R10 temporal guard read `firstDeclaredYear == null` as "covers the period" and
short-circuited the whole disjunction. `load.mjs` passes null whenever no history row
carried a parseable year, so every such link reached the seat rung with no temporal check
at all — publishing „Потвърдено" on a settlement match alone, which is precisely the
"company relocated INTO the declared settlement afterwards" case the guard exists to block.
That made the weakest rung the only one without a temporal test, on exactly the links where
we know least. Rung 4's refutation leg right below already refuses to run without a year.

An unknown year now FAILS the guard instead of skipping it. The undated-seat leg stays: a
seat with no entry date is the ordinary shape for a company that never moved, and it is
still checkable because a known year sits on the other side.

Three tests: both null-year shapes (dated and undated seat) held, plus a positive control
proving a known year with an undated seat still confirms — the fix is a bound, not a blanket.
The methodology page asserted this rule already; it now states the precondition it relied on.
…e and card claims

Four independent fixes, all from the deep-pass reviews still open at 9555fbf.

related-persons-data.yml — the environment guard read `${{ inputs.environment }}` inline in
two `run:` blocks. `type: choice` binds the UI only; a POST to .../dispatches can pass any
string, which an inline expression pastes into the shell before `[` evaluates it. Worse, that
same value decides whether the production guard fires, so a crafted one could inject AND walk
past the guard meant to trip on it. Both now read $SIGMA_SHIP_ENV, already bound one block up,
where the value is data rather than code. (deploy.yml:49 has the same shape and is untouched —
it belongs to another change.)

cache.mjs — the orphan sweep called unlinkSync unguarded, unlike the expired loop above it. It
runs AFTER the DB DELETE commits, so a file that vanished between readdir and unlink aborts a
half-finished purge: rows gone, files still on disk, run reported as failed, and no way to tell
"already gone" from "an orphan still holding third-party names". ENOENT is now tolerated and
uncounted; every other error still surfaces, because a purge that cannot delete has left PII
behind and reporting success is the failure it exists to prevent. `unlink` is injectable so the
race is tested rather than described.

conflicts.render.test.tsx — the family fixture inherited evidenceKind 'document' + registryRole
'owner' from the self-link factory, a row the query cannot produce: findPerson searches for the
OFFICIAL, and a relative's stake is registered to the relative, so only a seat/ЕИК confirmation
is reachable. It now mirrors production, and a new test pins what the old shape hid — a family
card must never render „вписано като съдружник/собственик", which would assert the named
official is recorded as an owner, on the one card designed to keep the stakeholder anonymous.

related-persons.ts — `lookup_date ?? ''` was an unreachable branch (NOT NULL in 0006, and the
row only arrives through the seal filter) that would have shipped an empty string as a date if
it ever ran. Narrowed like evidenceKind above it.

Not taken: the orphan sweep's `known` set was flagged for using raw `r.eik` while the expired
loop uses `safeEik(row.eik)`. safeEik VALIDATES rather than normalizes — it throws on a bad
shape — so for any storable ЕИК the two are the same string, and a non-canonical one makes the
deed unreadable through readDeed, which means the file really is unreachable PII and deleting
it is what the privacy rail should do.
Merging the crawl into the decision job forced the schedule from daily to monthly, which
contradicts ADR-0033's own consequence line ("decisions daily, registry lookups monthly").
That line is now false, and the truth lived only in a workflow comment — the one place a
reader looking for the decision would not go, and nowhere anyone could argue with it.

ADR-0034 records it properly: the two constraints that collide (raw deeds must not outlive
the runner, because an Actions cache entry sits under a restore-keys chain the 35-day
retention cannot reach; and the decision cannot be made without them, because the strongest
rung compares the declarant's name against the deed text the index deliberately refuses to
store), and both rejected alternatives — persisting the deeds behind someone else's eviction
policy, and having the crawl emit per-(link, ЕИК) verdicts so only booleans cross the
boundary. The second is the right way back to a daily decision and is named as such rather
than left implicit.

ADR-0033 is not rewritten (repo convention: an accepted ADR is superseded, not edited) — the
stale line carries a pointer to its successor and is scoped to the cadence claim alone. The
workflow comment now says it is the operational summary, not the record.

Also notes the consequence a monthly rebuild does NOT have: a takedown does not wait for the
schedule. It goes through ADR-0031 and, on live exposure, a direct UPDATE on the served D1.
The Dependency audit went red on a newly published advisory for nanoid 3.3.16
(GHSA-2v37-7h3g-55p8, CVSS 8.2), not on anything this branch changed — it touches neither
package.json nor pnpm-lock.yaml, so main fails identically on its next run.

Lockfile-only, and confined to nanoid: postcss@8.5.24 already declares `^3.3.16`, so 3.3.18
satisfies the existing range with no manifest edit and no override. `pnpm update nanoid`
does not lift a transitive pin; `-r` does.

Not suppressed in osv-scanner.toml on purpose — that file's own doctrine reserves entries for
advisories whose only fix is unavailable or a major upgrade, and this one is a patch.

Verified with the same checksum-pinned osv-scanner 2.4.0 the CI step runs: "No issues found".
The package is build-time only (postcss → vite → vitest, all dev) and reaches no Worker
bundle, but the chain is exercised anyway — full suite 6/6 packages, lint, typecheck 7/7, and
a real `@sigma/web` production build through postcss.
`closelyHeldForm` asked whether the name ENDS in a joint-stock form. A declarant who
types „ТРЕЙС ГРУП ХОЛД АД София" defeats that: the comma-peel needs a comma and
SEAT_MARKER needs a literal dot, so neither strips the seat, the name stops ending in
its form, and a listed АД reads as closely-held — the „11 акции на Trace → €88M" trap,
reached from the plainest input shape there is.

Decide on the LAST form TOKEN instead of an end anchor. Position-independent, so a
trailing seat cannot flip the verdict, while „АД" leading („АД ГРУП ООД") or glued
(„АД-ХОК ЕООД") still isn't the form. JOINT_STOCK itself is untouched — deed.mjs's
envelope test reads a registry-clean `fullName` that does end in its form, and
deed.test.mjs pins the two patterns to each other.

`stripSeatSuffix` gains the same cut for nameDistinctiveness, where the blind spot
fails toward PUBLISHING: an uncounted seat token inflates the content-word count to 3
and reads as distinctive. Truncation is anchored on фирма-TERMINATING forms only —
ЕТ/СД/КД precede the name, and cutting after them would eat it.

Positive controls throughout: a predicate that always returned false would pass every
new bar assertion, so each is paired with a name that must stay material.
…eadable

`if (!Number.isFinite(fy)) continue` looked like the surrounding fail-safe — "never
withdraw on missing evidence" — but it is the opposite. An absent filing legitimately
yields no horizon; a PRESENT filing that is silently dropped never advances one, so
`divested` stays false and a stake the official has since sold keeps naming them on the
public surface. Stale claim, real person, no symptom in the output.

filings.jsonl already carries `folder` (extract.mjs), so fall back to it. The folder is
the PUBLICATION year and runs ahead of the declared year, so the horizon can advance up
to a year early — that errs toward withdrawing a claim we are no longer sure of, which
is the safe direction on this surface. Documented at the fallback rather than left for a
reader to infer.

A filing datable by NEITHER field is still ignored: this dates a filing, it does not
invent one. Both counts are reported, because a horizon that failed to advance is
invisible — without the counter, a corpus-wide date regression would show up only as a
surface that quietly stopped withdrawing anything.

The RED publishes via the SEAT rung, not rung 2: an owner-matched deed would let §7
reconciliation reverse the divestment and mask the bug. Mutation-checked — removing the
fallback fails the test with published/withdrawn. Positive control: an undatable filing
leaves its link published.
`colNum` always returns a fallback, so a table that renumbers the holder column resolves
it to a column that is not in the row. `by[cHolder]` is then undefined, the caller reads
'' and `classifyHolder` maps a blank cell to 'self' — its documented meaning. A
RELATIVE's stake becomes the official's own, enters the OWN-only refutation path and
publishes as their private_ownership.

The distinction the parser needs is column RESOLVABLE-BUT-EMPTY (self, a blank cell does
mean the declarant) versus column NOT RESOLVABLE (unknown — we never read the holder, so
we make no claim). `colNumOrNull` supplies it for the holder column alone; the other five
call sites keep `colNum` and its fallback verbatim.

Scope: parse.mjs was byte-identical to main. It is touched here because midt-bg#279 §5 item 4
scheduled §1.4 for this PR, and this defect fails toward publishing a stake against a
person who did not declare it as theirs.
…only the person

ADR-0033 held that rung 2 needs no gate because "the register named this person in THIS
company". That holds only if THIS company is the one the official declared, and it is not
guaranteed. `resolveEntity` maps a declared фирма to the sole ЕИК among PROCUREMENT
WINNERS, and `nameGloballyUnique` ranges over bidders — never the register at large. So
an official who owns a same-named company that never bid resolves to an unrelated winner,
and a three-token homonym in that winner's deed completes a link false in both halves.
Two coincidences, neither rare in Bulgaria. The ladder moved the name-collision risk into
the Register instead of closing it, which is the premise midt-bg#279 rests on.

Rung 2 now asks for a reason to believe the company is the declared one: a declared ЕИК
(the identifier resolves it outright), a declared seat matching the registered one, or a
фирма distinctive enough that a national twin is improbable. Anything else returns a new
withholding kind `document_uncorroborated` — never publishing, carrying neither role nor
matched_fact, since asserting either would leak the claim the rung just refused to make.

It is a distinct kind rather than a fall-through to `unknown` because "matched a person,
could not establish the company" and "matched nothing" are different facts, and the seal
exists on held links precisely to keep that queue reviewable. It is counted in the run
summary: the third corroborator is a bound, not a proof, and that number is what F8 reads
to decide whether this tightens to strict ЕИК/seat corroboration. Strict was the
alternative; declared seats come only from the ООД/ЕООД table of asset declarations, so
its recall cost cannot be known before that measurement.

`companyNameDistinctive` defaults to FALSE — this gates the primary publishing rung, so a
caller that forgets it must withhold. Rungs 2 and 3 now share one `matchDeclaredSeat`,
R10 included, so they cannot drift about what a seat match means.

Mutation-checked three ways: never-withhold fails 4 tests, always-withhold fails 7 (the
positive controls hold it to a bound), and dropping R10 from the shared helper fails 2.
Covered end to end in load.test.mjs, not only as a pure function.

ADR-0035 records it and supersedes ADR-0033 decision 2 on this point alone; the
methodology page discloses the rule verbatim per ADR-0021 E10.
… natural key

midt-bg#279 §2 asked for CHECK constraints and a non-null control_hash. The enums are done as
asked; the control_hash half is not, and the reason matters.

Enums. `status` and `interest_class` (0003) and `evidence_kind`/`registry_role`/
`live_status` (0006) were plain TEXT while the surface reads them as enums. A value that
only LOOKS like a gate value — 'published ' with a trailing space, the case the ticket
names — passes every writer and then fails `status = 'published'` silently. A CHECK binds
every writer at once, including a hand-run UPDATE during an incident, which is exactly
when it gets typed. 0006 is unshipped in this PR, so its constraints go on directly.

control_hash NOT NULL is REJECTED, and the underlying defect fixed differently. The field
is genuinely optional at the source — the register omits <ControlHash> and parse.mjs
carries that through as null — so NOT NULL converts a missing optional field into a
run-stopping loader failure, and a fabricated placeholder would assert an integrity check
nobody performed. The real defect is that `UNIQUE (xml_file, control_hash)` never
constrained re-import: SQLite counts NULLs as DISTINCT, so every hashless declaration was
mutually unique and re-imported as a fresh row each run, double-counting its stakes.
Replaced by a unique index over (xml_file, folder_year, COALESCE(control_hash,'')) — the
same natural key `id` already encodes, since the register reuses basenames across folders.

0007 retrofits both onto a deployed D1. `CREATE TABLE IF NOT EXISTS` never revisits an
existing table and ship-related-persons wipes ROWS, not definitions, so a live database
would otherwise keep the unconstrained 0003 shape forever however often the data reloads.
interest_links needs a rebuild (SQLite cannot add a CHECK in place) under
defer_foreign_keys, since interest_link_evidence references it and D1 enforces FKs;
declarations needs only the corrected index. Both converge on re-application, which the
bare `d1 execute --file` path requires, and both workflows apply it.

Tests cover the trailing-space case by name, the ADR-0035 kind, the hashless duplicate
that used to slip through, and a pre-0007 database that keeps its rows through the
retrofit and survives a second application. Positive controls throughout: every real
status and class still inserts, and the natural key still admits the same basename in a
different folder and a corrected re-filing under a new hash.
… all four copies

The detail-page gate is belt-and-braces — SURFACED_OWNERSHIP requires `status='published'`
AND a publishing seal. The company-search badge checked status alone, so a published row
whose seal was missing or withholding (a legacy row, a partial run, a loader bug)
advertised a свързани-лица claim about a named official to every searcher, while the page
behind the badge correctly showed nothing. Search is the wider surface of the two: it is
what a reader sees before deciding to look.

The table probe now requires interest_links AND interest_link_evidence. The join reads
both, so an env with 0003 but not 0006 would 500 every search on every kind — the exact
failure the probe was written to prevent. Requiring both also gives the right answer
there: with no seal table nothing is provably sealed, so nothing should badge.

The predicate now has four copies, and nothing but a test can bind SQL across three
languages and two file types. related-persons-sql.test.ts asserts all four agree on both
the EXISTS shape and the rung list, whitespace-insensitively. refresh-slice.sql is in that
set for its own reason: it runs on the 6-hourly cron, so drift there silently keeps
officials in the search index whose links no longer surface.

Mutation-checked: adding a rung to refresh-slice.sql alone fails both binding tests. The
badge fixture carries an unsealed published link and a withholding-sealed one, each of
which must not badge, plus sealed links on both publishing rungs that must.
…b to staging

§2.6 made the CARD labels tense-neutral and family-aware but left the surrounding page
copy asserting „собствен дял" — an OWN stake — directly above cards that correctly read
„деклариран дял на свързано лице". On a family-only page that is a false claim about the
named official, and it is exactly the second source of truth the card-label fix set out
to remove.

`declaredStakeNoun` derives the wording from the links the page is actually rendering, so
a page cannot describe a set it isn't showing. Family-AWARE rather than family-blind: an
own-stake page still says „собствен дял" (a positive control covers this — deleting the
claim everywhere would pass the family assertion while making the page vaguer than the
data warrants), and a mixed set gets the only phrasing true of every card.

The ledes also stopped claiming „точно съвпадение по фирмено име" — since midt-bg#279 identity
rests on a Trade Register fact, not on the name.

related-persons-data.yml offered `default: dev` while no `dev` environment exists, every
fallback in the file is `|| 'staging'`, and the note at the top says the job targets
staging. A manual dispatch therefore went to an environment with no secrets and failed at
the credential guard rather than running where intended. Dropped from the options too, so
it cannot be picked deliberately either.

Methodology: „премахва незабавно" now distinguishes the D1 write (immediate) from what a
reader sees, which the page's own publicCache(3600) can hold for up to an hour. The
promise was not wrong about the action, only about when it becomes visible.
…nd the rule disclosure

All three gaps were named by reviewers, and none had a bug behind it — but each guards a
path where failure is silent, which is the case for testing them rather than trusting
them.

Audit axes A_key_missing, A_eik_mismatch, B_bidder_eik and B_eik_invalid fired in no
test. The audit is the last gate before a named claim ships, so an axis that quietly
stopped firing would be invisible: A_eik_mismatch is the libel case in its purest form (a
name resolving to one winner, the link published against another), and B_eik_invalid is
the only rail keeping a checksum-invalid ЕИК off the surface. Mutation-checked — disabling
B_eik_invalid fails its test — and paired with a positive control, since four assertions
that something fails prove nothing unless the clean shape passes.

Every rung-2 owner test used CR_F_19_L, leaving CR_F_18_L (едноличен собственик) and
CR_F_23_L (ФЛ-търговец) unexercised. A dropped entry there would withhold an entire
ownership shape — a sole owner, the commonest ЕООД form — as „Неизвестна", a recall hole
with no symptom. Controlled by a non-ownership field code that must NOT match.

The methodology page's rule disclosure is a launch condition under ADR-0033 decision 7,
not a nicety, and nothing kept it in step with the ladder. The rule could change in
evidence.mjs and leave the page describing a system that no longer exists — worse than
not disclosing it. Now pinned: every rung by name, the joint-stock bar, the three-names/
one-record rule, ADR-0035's company gate, both halves of R10, the no-ЕГН homonym limit,
and the company-identity-not-ownership distinction the surface rests on.
…rebuild

`wrangler d1 migrations apply` rejected the create-copy-drop-rename rebuild:
interest_link_evidence references interest_links(link_key), D1 enforces foreign keys, and
`PRAGMA defer_foreign_keys` does not survive the statement-by-statement execution wrangler
performs — the migration aborted with SQLITE_CONSTRAINT_FOREIGNKEY and the Durable Object
rolled back. The sqlite3-CLI test passed because that CLI has foreign keys OFF by default,
so only a real local apply surfaced it.

Dropping the child first would work mechanically and is the wrong trade: it strips every
evidence seal, and since the read gate REQUIRES a seal, that empties the public surface
until the next monthly data run.

BEFORE INSERT/UPDATE triggers enforce the identical invariant for every writer — including
the hand-run UPDATE during an incident that no application-level validation covers — with
no rebuild, no FK exposure and no seal loss. Fresh databases still get true CHECK
constraints from 0003/0006; this is only the retrofit path for databases that already
exist.

Verified against real local D1: applies clean, and a second application is a no-op. The
test now also covers UPDATE, and asserts that a pre-existing bad row SURVIVES — a trigger
constrains future writes and cannot retroactively reject stored data, and deleting a real
link on a guess would be worse than leaving one the read gate already refuses to show.
Brings the branch current before opening the upstream PR (AGENTS.md: pull main in
if a branch lingers). Merged rather than rebased — the branch is public and under
review on the fork PR, so its history must not be rewritten.

nanoid: upstream midt-bg#291 made the same 3.3.16 → 3.3.18 lockfile move as 36cdd9d and
additionally pinned an override; both sides agree on the resolved version, so the
lockfile merges to one consistent state.
The merge with upstream/main was textually clean and semantically broken — `git
merge-tree` reported no conflict, and four suites then failed. Both sides had edited
refresh-slice.sql and ship-related-persons.mjs, and upstream's new harnesses build their
schemas from migrations that stop at 0003, while this branch made both files depend on
interest_link_evidence (0006).

- refresh-slice.test.ts: upstream's new OCDS-bridge site (midt-bg#286) was a third setup site
  this branch had not seen; without 0006 sqlite3 aborts on "no such table" at
  refresh-slice.sql:1957, before reaching the logic under test.
- value-flag-stotinki-sql.test.ts / value-flag-annex-step-sql.test.ts: new files, same
  cause — they died on the свързани-лица block while testing the value_flag CASE.
- ship-e2e.test.ts: the evidence seal is now a shipped table, so a target built from 0003
  alone makes the generated wipe abort before a row moves. Its corpus now seeds one seal
  per link, which is the only shape the ship should ever carry — a published link without
  one is what the audit fails on and what the read gate refuses to surface.

One behaviour change worth naming. `a request that never landed fails the run` injected a
skipped interest_links chunk; with seals shipping, that now trips the FOREIGN KEY as the
orphaned rows land, before the read-back runs. Stronger, but it leaves the read-back gate
— the thing the test exists for — unexercised. The injection moves to a seal chunk, which
has no dependents, so the shortfall stays invisible until the counts are compared.

All suites green on the merged tree: 421 db, 492 web, 61 ingest, 45 shared, 20 etl, 10
config, 91 root scripts, 248 pipeline; lint, typecheck, check:docs and the coverage
ratchet clean.
@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах на ac15498 с уговорката, че не съм минал всичките 83 файла. Съсредоточих се там, където грешка е необратима: доказателственият ред, съвпадението по имена и схемата. Едно блокиращо нещо и едно за координация.

Блокер: миграция 0003 се редактира на място, а CHECK-овете никога няма да стигнат до сервираните бази

PR-ът добавя два CHECK в interest_links (status, interest_class) вътре в вече приложената 0003_related_persons_foundation.sql.

Проверих живата stage база:

SELECT sql FROM sqlite_master WHERE name='interest_links';
-- status         TEXT NOT NULL DEFAULT 'held',            ← без CHECK
-- interest_class TEXT NOT NULL DEFAULT 'management_role', ← без CHECK

И няма как да се появят: CREATE TABLE IF NOT EXISTS е no-op върху съществуваща таблица, а SQLite не може да добави CHECK с ALTER TABLE. deploy.yml прилага 0003 при всеки деплой - и тя ще мине без ефект.

Това е по-лошо от липсващ CHECK, защото коментарът в кода обещава точно обратното:

„A CHECK binds every writer at once, including a hand-run UPDATE during an incident, which is exactly when this is most likely typed."

Ръчният UPDATE по време на инцидент се случва именно върху сервираната база - тоест на единственото място, където гейтът няма да съществува. А темата е публикуването на поименни връзки, където status = 'published ' с интервал е сценарият, който самият текст назовава.

Отделно се получава и разминаване между средите: нова база (изградена от редактираната 0003) няма таблично UNIQUE (xml_file, control_hash), а заварените го запазват. Днес е безобидно, защото новият уникален индекс върши работата, но две различни схеми под едно име рано или късно се разминават и по нещо, което не е безобидно.

Иска се отделна миграция, която пресъздава таблицата по 12-стъпковата процедура на SQLite (нова таблица с CHECK → копиране → drop → rename), или обещанието да отпадне от коментарите и гейтът да се носи от приложния слой. Първото пази намерението; второто поне не заблуждава следващия четец.

За координация: три PR-а добавят миграция 0006

Два от тях носят и 0007. Който влезе втори и трети, ще дублира номера. Понеже сервираните бази се пълнят и извън ledger-а, това е точно мястото, където подредбата тихо се разпада. Ще трябва преномериране при мърдж - не е дефект на този PR, но го засяга.

Какво проверих в клеветническата част и държи

Тази част е написана както трябва и си струва да се каже изрично, защото проверих механизмите, не описанието:

  • Тритокенната порта е реална. MIN_NAME_TOKENS = 3, а eligibleForDocument = !shortName && !latinInName пази стъпало 2. Име с две части не може да публикува - точно рискът, който самият коментар назовава („46 от 301 съвпадения бяха двутокенни").
  • Съвпадението е в рамките на един запис. entityBlocks реже и по <hr>, и по границата на record-container, тъй че премахването на едното не отваря дупка. Тестът първо демонстрира грешката (наивното съвпадение върху цялото поле пламва за смесено име), после я заковава - това е верният начин да се пази такъв инвариант.
  • Латиница прави несъвпадение, не фалшиво съвпадение, и се брои отделно.
  • Заличените обстоятелства не могат да произведат съвпадение, а противоречието „заличено, но с текст" вдига тревога вместо да мине тихо.
  • Гейтът за самоличност на дружеството (ADR-0035) връща отделен вид document_uncorroborated, който не публикува и не носи нито роля, нито matched_fact - тоест не изтича твърдението, което току-що е отказал. Разграничението „не намерихме нищо" срещу „намерихме човек, но не установихме дружеството" е точно разграничението, което ревю опашката трябва да вижда.
  • matched_fact е затворен речник - role:<роля>:<код на поле>, без имена.
  • scripts/tr минава 134/134. Четирите падания в scripts/cacbg при мен са от версията на Node (заданието иска 24, локално имам 22) - в CI са зелени, не са находка.

Обобщение

Съдържателната част - смяната на основанието от отличителност на името към регистърно доказателство - е внимателно написана и защитена там, където трябва. Блокиращото е схемата: CHECK-овете, които текстът представя като гейт, няма да съществуват на базата, която обслужва сайта. Оправя се с отделна миграция.

@cefothe

cefothe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Code review — parallel multi-agent (/code-review-pr)

Reviewed at head ac15498 across 7 dimensions. CI green (cacbg, check, semgrep, test). Findings cross-checked against a full live end-to-end run of this pipeline (fresh D1 + all 36 declaration folders → 518 TR lookups → 355 published links).

Phase 0 — security-critical scan: ✅ CLEAN

No hardcoded secrets (the "token" hits are prose about three-token names). URLs are the two intended sources — register.cacbg.bg and portal.registryagency.bg (the #279 endpoint, approved in ADR-0033) — plus evil.example/https://x, which are adversarial URL-rejection test fixtures. No malicious patterns (execFileSync only in ETL scripts). No dependency changes.

Composite: 8.5 / 10

Dimension Score Verdict Headline
Security 8.5 APPROVE PII rails, SSRF gate, parameterised SQL, fail-closed salt sound; CI devstaging fixed
Performance 6.0 REQUEST_CHANGES #226 authorities-join correctness defect re-shipped
Architecture 8.5 APPROVE Faithful to ADR-0033/34/35; 4-copy de-anon gate is test-bound
Database 7.0 approve w/ warnings enum CHECKs + control_hash dedup fixed; 0007 unguarded DELETE
Tests 2.6/3.0 APPROVE 339 script + 421 db tests green; every rung/fix has ± tests
Frontend 8.5 APPROVE "собствен дял" fixed & test-pinned; deferred labels absent
Docs 9.5 APPROVE ADR bookkeeping clean; control numbers framed as reproducibility checks

One real should-fix-before-merge

Dead authorities INNER join drops rows & undercounts money — in 3 sites. packages/db/src/queries/related-persons.ts:95-98 (and :344-348, scripts/refresh-slice.sql:1966-1969) still join contracts→tenders→authorities→bidders; tenders/authorities are never projected, and the authorities inner join silently drops any contract whose authority is unresolvable — from the money SUM, the "held during award" chip, and the EXISTS gate that decides whether a link shows at all (related-persons.ts:189). Fix: FROM contracts cc JOIN bidders bb ON bb.id = cc.bidder_id in all three. This is #279 §5 item 1.6, not addressed here.
Caveat: the maintainer previously measured 0 unresolvable-authority contracts on the real corpus, and the live run (355 links / 198k contracts) rendered correctly — so this is latent, not firing today. Real defect, cheap fix, no current impact.

Non-blocking fast-follows

  • Deploy safety: 0007's DELETE FROM declarations (dedup) runs unguarded on live data — wrap in a transaction + pre-flight COUNT (0007:43).
  • Cheater tests (×2): scripts/tr/evidence.test.mjs:456 & scripts/cacbg/load.test.mjs:1244 assert matched_fact against a locally-redefined looser regex than production MATCHED_FACT_RE, so they can't fail on a seat-token regression. (That regression is caught by audit.test.mjs:229, so no libel rail is left uncovered.) Import the real MATCHED_FACT_RE/isSealedFact and add a direct unit test for it.
  • Frontend: registryEntryNumber is in the DTO/query/fixtures but never rendered (ConflictCards.tsx:189-199); ADR-0033 says the entry number shows on every link.
  • Docs: SUPPRESSION_KEY_VERSION is mislabelled a secret (it's a repo variable) in runbooks/related-persons-suppression.md:115; runbook has no "verify the takedown worked" step; deploy.md:172-173 ("deploys do not migrate") is now contradicted by deploy.yml applying 0003/0006/0007.
  • Coverage: scripts/ (~7k lines of libel-critical logic) sits outside the coverage ratchet — document it as a reviewed carve-out in docs/review-testing.md.

Verified strong (fork-review defects fixed & test-pinned)

§1.1 closelyHeldForm "last-form-token-wins" (catches '…АД София'), §1.3 undated-year folder fallback (a sold stake is now withdrawn), §1.4 empty-cell vs unresolvable-column, §2 enum CHECKs + control_hash dedup via COALESCE(...,'')+folder_year. ADR-0035's company-identity gate (document_uncorroborated, fail-closed) is new and well-covered. Monotonicity gate with both opposing-direction removal rails, deferred phase-2 labels, closed matched_fact vocabulary, and the deed-cache PII rails are all faithful. The live run produced 355 published (267 document / 88 confirmed) — close to ADR-0033's control of 329 (251/78), validating the pipeline end to end.

Recommendation

REQUEST_CHANGES, narrowly — the authorities-join correctness defect holds it below the >9.0 gate. In practice this is a merge-ready PR needing one cheap correctness fix (authorities join ×3) + one deploy-safety guard (0007 DELETE); the rest are nits. Not a security or architecture blocker; a maintainer aware the authorities-drop doesn't fire on the current corpus could reasonably approve-and-fast-follow.

Multi-agent review via /code-review-pr (security, performance, architecture, database, testing, frontend, docs), assisted by Claude Code — findings verified at file:line and against a live e2e run.

…y database

0003 is already applied everywhere, so adding CHECKs to it was the wrong move even though
that is where the table is declared. `CREATE TABLE IF NOT EXISTS` is a no-op against an
existing table and ship-related-persons wipes ROWS, never definitions — so the constraints
would have existed only on databases built after the edit. Two schemas under one name,
with the gate absent precisely on the served database, which is where a hand-run
`UPDATE … SET status='published '` during an incident actually lands. The comment promised
the opposite of what the deployed base would have had.

0003 is restored byte-for-byte and enforcement moves entirely into 0007, which both paths
reach: a fresh chain runs it after 0003, a deployed database gets it as a retrofit. 0007
already carried the triggers, the natural-key index and the dedup, so nothing is lost —
enforcement simply becomes uniform. A fresh build keeps 0003's table-level UNIQUE too;
it treats NULLs as distinct, so it is strictly weaker than the index and rejects nothing
the index accepts. 0006 keeps its own CHECKs: it is new here and has never been applied to
a served environment, so declaring them there edits no applied history.

The parity test is the point: a fresh 0000..0007 build and a legacy database retrofitted
by 0007 must reject the SAME values and accept the SAME ones, including the UPDATE path no
CHECK on a legacy table would ever have covered. Mutation-checked — dropping the UPDATE
trigger fails it.

Verified on a real local D1 built from the reverted 0003, not only in tests: the bad
UPDATE returns SQLITE_CONSTRAINT_TRIGGER and the legitimate one succeeds.

The dedup DELETE now announces itself before changing live data, so a deployment that
drops declarations says how many instead of leaving a row count nobody recorded. Its
subtraction is parenthesised because `||` binds tighter than `-` in SQLite — the first
real apply printed "notice: -2" and proved it. No explicit transaction: `d1 execute
--file` already runs the file as one, so a later failure rolls the DELETE back with it.
… looser copy

Both seal tests restated MATCHED_FACT_RE locally, and both got it wrong in the permissive
direction: `seat:` followed by unlimited uppercase tokens, where production bounds a
settlement to two. So `seat:ИВАН ПЕТРОВ ГЕОРГИЕВ` — a three-part Bulgarian name (ЗГР чл. 9)
wearing an allowed prefix, which is exactly what a mis-split seat field produces — passed
both tests and fails the real rail. They could not fail on the regression they exist to
catch, which is the one shape this rail keeps off a served column.

Both now import `isSealedFact` from the module that WRITES the vocabulary, so the
assertion and the enforcement cannot drift apart again.

Adds the direct unit test neither file had: MATCHED_FACT_RE accepts real one- and
two-token settlements including a hyphenated one, accepts every role/field-code shape and
`eik`, and rejects the three-token seat, a bare name, a name where a field code belongs, a
role outside the vocabulary, an empty settlement and a seat carrying the ЕИК itself.

Mutation-checked: widening the production bound to the old local pattern now fails with
"wrongly accepted: seat:ИВАН ПЕТРОВ ГЕОРГИЕВ" — under the previous tests that same change
was silent.
…orrect the docs

registryEntryNumber was in the DTO, mapped in the query and present in every fixture, and
rendered nowhere — carried to every client for nothing. It is also what makes the claim
findable: a date does not identify a record in the register, and a reader checking whether
we read the act correctly needs the number. Shown only when present, since a seat/ЕИК
confirmation cites no act entry and an empty „№" would read as missing data rather than an
inapplicable field.

(cefothe cited ADR-0033 as requiring this. It does not — no such text exists there. The
dead payload stands on its own.)

The contract join is now pinned writer-to-reader. On the read side `tenders`/`authorities`
are never projected, so both joins look dead and invite deletion — a reviewer proposed
exactly that. They are not dead, they are the SHAPE: the writer at load.mjs stores
contract_count/contract_value_eur through the same four tables, so deleting them on the
read side alone would let the read exceed the stored aggregate and surface links the
zero-contract gate excluded. Deleting them on BOTH sides is defensible but changes
published figures and needs re-baselining against ADR-0033 §10, so it stays midt-bg#226 §1.6.
A comment cannot survive a refactor; the test compares both by relation and join
condition. Mutation-checked in both directions.

Docs: SUPPRESSION_KEY_VERSION is a repository VARIABLE, not a secret — looking for it
under Secrets during an incident is a dead end. The suppression runbook gains the
verification it lacked: a takedown that silently failed looks exactly like one that
worked, so it now says to check the served D1 and the page, allow for the 1h public cache,
and re-run the next load to prove the removal survives a rebuild. deploy.md no longer
claims deploys do not migrate — deploy.yml applies 0003/0006/0007 explicitly, and why.
review-testing.md records scripts/ as a reviewed carve-out from the ratchet, naming the
three runners and the mutation discipline that stands in for a percentage.
@ydimitrof

Copy link
Copy Markdown
Contributor Author

Благодаря и на двамата. Възпроизведох всяка находка срещу ac15498 преди да пипна код; три предпоставки не издържаха тази проверка и ги отбелязвам отдолу вместо да ги реализирам мълчаливо.

Кръпките са ac15498..2813726 (3 комита).


Блокерът (@todorkolev) — затворен, и по начина, който предлагаш

Прав си, и по-точно, отколкото звучи. Не само че CHECK-овете не стигат до сервираните бази — редактирането на вече приложена миграция създава две схеми под едно име, като липсващият гейт се пада точно на базата, която обслужва сайта. Тоест на единственото място, където ръчният UPDATE ... SET status='published ' по време на инцидент наистина се случва. Коментарът обещаваше обратното на това, което заварената база щеше да има.

0003 е върната байт по байт (git checkout upstream/main -- …0003…, diff-ът срещу upstream/main е празен), а цялото налагане мина в 0007, до което се стига по двата пътя: нова верига я прилага след 0003, заварена база я получава като ретрофит. 0007 вече носеше тригерите, индекса за естествения ключ и дедупа — нищо не се губи, налагането просто става еднакво навсякъде.

Свежа база запазва и таблично UNIQUE (xml_file, control_hash) от 0003. То брои NULL като различни, тоест е строго по-слабо от новия индекс и не отхвърля нищо, което индексът приема — така че разминаването между средите, което посочи, изчезва: индексът управлява и на двете.

0006 запазва собствените си CHECK — тя е нова в тази промяна и никога не е стигала до сервирана среда, тоест там не се редактира приложена история.

Защо тригери, а не пресъздаване по 12-стъпковата процедура. Опитах точно това първо. wrangler d1 migrations apply го отхвърли: interest_link_evidence сочи към interest_links, D1 налага външни ключове, а PRAGMA defer_foreign_keys не оцелява при изпълнението по едно изречение, което wrangler прави — миграцията прекъсва със SQLITE_CONSTRAINT_FOREIGNKEY и Durable Object се връща назад. Тестовете със sqlite3 CLI минаха, защото там външните ключове са изключени по подразбиране. Изхвърлянето на детето първо работи механично, но маха всеки печат, а понеже гейтът за четене изисква печат, това изпразва публичната повърхност до следващия месечен пуск.

Проверено срещу истинско локално D1, изградено от върнатата 0003 — не само в тестове:

UPDATE interest_links SET status='published ' WHERE link_key='…'
✘ CHECK failed: interest_links.status/interest_class outside the publishing-gate enum:
  SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_TRIGGER)

UPDATE interest_links SET status='published'  WHERE link_key='…'   → success

И тъй като „едната схема" е твърдение, а не факт, докато някой не го провери: нов тест държи свежа верига 0000..0007 и заварена база, ретрофитната от 0007, да отхвърлят едни и същи стойности и да приемат едни и същи — включително пътя UPDATE, който никакъв CHECK върху заварена таблица никога не би покрил. Мутационно проверено: махането на UPDATE тригера поваля теста.

Находки на @cefothe

Фалшивите тестове ×2 — вярно, поправено, и беше по-лошо, отколкото звучи. И двата теста преповтаряха MATCHED_FACT_RE локално и и двата грешаха в разрешаващата посока:

                              локален  продукционен
seat:ИВАН ПЕТРОВ ГЕОРГИЕВ     true     false

Тоест три части на българско име (ЗГР чл. 9) с позволен префикс — точно това, което дава сгрешено разделяне на полето за седалище, и точно формата, която тази рамка съществува да държи извън сервирана колона. И двата вече внасят isSealedFact от модула, който пише речника. Добавен е и директният тест, който липсваше и на двата: приема едно- и двутокенни населени места (вкл. с тире), всички кодове на роли и полета, и eik; отхвърля тритокенното седалище, голо име, име там, където се очаква код на поле, роля извън речника, празно седалище и седалище, носещо самото ЕИК. Мутационно проверено — разширяването на продукционната граница до стария локален шаблон вече пада с „wrongly accepted: seat:ИВАН ПЕТРОВ ГЕОРГИЕВ"; при предишните тестове същата промяна беше тиха.

0007 DELETE — вярно, поправено. Изтриването се обявява преди да смени живи данни, тъй че пуск, който маха декларации, казва колко, вместо да оставя брой редове, който никой не е записал предварително. Изрична транзакция не е добавена: d1 execute --file вече пуска файла като една, тъй че по-късен провал връща и DELETE-а. Между другото първото реално прилагане отпечата notice: -2|| се свързва по-силно от - в SQLite — което е и причината скобите да са изрични, с коментар защо.

registryEntryNumber — вярно като мъртъв полезен товар, но не по посочената причина. В ADR-0033 няма такова изискване; проверих. Находката обаче стои и без него: полето пътуваше до всеки клиент и не се показваше никъде, а датата не идентифицира запис — номерът е това, което читателят въвежда, за да намери същия акт. Показва се само когато го има: потвърждаване по седалище/ЕИК не цитира вписване, а празно „№" се чете като липсващи данни, вместо като неприложимо поле. Проверено и в браузър срещу реален Worker + D1 + SSR: № 20220314150210 · вписване 2022-03-14 · справка 2026-08-05.

Документацията — вярно, поправено. SUPPRESSION_KEY_VERSION е репо променлива (vars.), не тайна — да я търсиш в Secrets по време на инцидент е задънена улица. Ръководството за сваляне получи проверката, която му липсваше: сваляне, което тихо се е провалило, изглежда точно като успешно, затова сега казва да се провери сервираното D1 и страницата, да се отчете кешът от 1 ч. и да се пусне следващото зареждане, за да се докаже, че премахването преживява повторно изграждане. deploy.md вече не твърди, че деплоите не мигрират. review-testing.md записва scripts/ като прегледано изключение от ratchet-а, с трите runner-а и мутационната дисциплина, която замества процента.

Присъединяването към authorities — не го пипам, и ето защо

Проверих и трите места, и картината е различна от описаната.

Мъртво е на 2 от 3. LINK_CONTRACTS_SQL:333 проектира aa.name, aa.id, tt.title, tt.procedure_type — там присъединяването носи товар.

По-важното: писачът използва същото присъединяване. scripts/cacbg/load.mjs:556 пълни contract_count / contract_value_eur през същите четири таблици. Затова коментарът при CONTRACT_JOIN е точен: четенето е нарочно подмножество на съхранения сбор.

Предложената поправка — само откъм четенето — би направила четенето по-широко от сбора: contemporaneous_contract_count може да надмине contract_count, а гейтът EXISTS на related-persons.ts:189 (този, който решава дали връзка изобщо се показва) би публикувал връзки, които гейтът за нула договори е изключил. Тоест поправка в посоката, в която surface-ът греши към публикуване.

Промяната от двете страни е защитима — днес неразрешим възложител изпуска договор от парите последователно навсякъде — но променя публикувани числа, а те са точно това, което равносметката по §10 сверява. Значи иска нов пълен пуск за нова базова линия, не почистване между другото. Остава проследено като #226 §1.6.

Вместо това инвариантът вече е закован с тест: коментар не преживява рефакторинг, тъй че related-persons-sql.test.ts сравнява двете страни по отношение и условие на присъединяване (различни езици, различни псевдоними — сравнима е само формата). Мутационно проверено и в двете посоки: махането на authorities от писача поваля 2 теста, от четеца — 1.

Номерата на миграциите

Потвърждавам: #307 взема 0006+0007, което се сблъсква с нашите. #308 вече е минала на 0008, тъй че реалният сблъсък е само с #307.

Не преномерирам сега: нашата 0006 вече е приложена (sigma-279, а вероятно и другаде), а преномериране на приложена миграция разминава d1_migrations — точно режимът на провал, за който предупреждаваш. Който влезе втори, преномерира. Кажете, ако предпочитате друга последователност — при мърдж е тривиално, преди него не е.

Проверка

pnpm lint · pnpm typecheck · pnpm check:docs · pnpm check:coverage   чисто
pnpm test        424 db · 493 web · 61 ingest · 45 shared · 20 etl · 10 config
node --test scripts/*.test.mjs                                        91
… --test scripts/cacbg/*.test.mjs scripts/tr/*.test.mjs              249

Браузърна проверка срещу реален Worker + D1 + SSR/хидратация: 24/24. Мутационни проверки на трите нови гейта (паритет на схемата, речник на печата, обвързване писач↔четец) — всеки поваля теста си при обърнато условие.

И една благодарност по същество

@cefothe, живият пуск от край до край (36 набора → 518 справки → 355 публикувани, 267 „Документ" / 88 „Потвърдено") е първото реално потвърждение, че конвейерът дава смислени числа срещу контролата на ADR-0033 от 329 (251/78). Това е точно данните, по които F8 стоеше блокирана. Ако някъде има артефакт от пуска — броячът document_uncorroborated в обобщението на зареждането е числото, което решава дали гейтът по ADR-0035 да се стегне до строго потвърждаване; би било много полезно.

@todorkolev

Copy link
Copy Markdown
Collaborator

Прегледах на 2813726. Блокерът е затворен, и то по-добре от това, което предложих.

Аз казах „пресъздайте таблицата по 12-стъпковата процедура". Вие показахте защо това е грешният ход тук - пресъздаването излага външните ключове и обира доказателствените печати, което изпразва публичната повърхност до следващото месечно зареждане - и вместо това оставихте 0003 недокосната, а гейта преместихте изцяло в 0007 през тригери. Това е верният отговор: един механизъм, който важи и за новоизградена, и за заварена база.

Проверих го, не го приемам по описание:

0003 наистина е непокътната. Разликата спрямо main е празна. И повече от това - изтеглих sqlite_master от живия stage и сравних: разгърнатата таблица съвпада с върнатата 0003 дума по дума, включително коментарите. Тоест файлът вече описва точно това, което е приложено.

0007 е повторяемa. Приложих я три пъти подред върху свежа верига:

migration 0007: collapsing 0 duplicate declaration row(s) …   ← 1-ви път
migration 0007: collapsing 0 duplicate declaration row(s) …   ← 2-ри път
migration 0007: collapsing 0 duplicate declaration row(s) …   ← 3-ти път

и след третото прилагане гейтът още хапе:

Error: CHECK failed: interest_links.status/interest_class outside the publishing-gate enum

Това има значение, защото deploy.yml я пуска при всеки деплой.

Тестът за паритет е истински. Изгражда двете форми - свежа 0000..0007 и заварена, върната до безограничителния вид - и ги държи на едни и същи откази и едни и същи приемания, включително пътя през UPDATE, който никой CHECK върху заварена таблица не би покрил. Това е разликата между „твърдим, че двете съвпадат" и „ако се разминат, пада тест".

db 424/424.

Отделно: замяната на местното копие на регулярния израз с внесения производствен предикат в evidence.test.mjs е точно поправката, която такъв тест иска - копието беше по-широко от рейла и заверяваше стойности, които истинският отхвърля.

Едно остава: коментарът в deploy.yml описва предишния замисъл

docs/deploy.md е оправен и говори за тригери. Блокът над прилагането на 0007 в самия deploy.yml обаче е останал от преди и сега твърди три неверни неща:

# 0007 retrofits the publishing-gate CHECK constraints and control_hash NOT NULL onto an
# ALREADY-PROVISIONED D1 (#279 §2). 0003 declares them now, but `CREATE TABLE IF NOT EXISTS`
#
# Rebuild-based and therefore idempotent: re-applying copies an already-constrained table.
  • „0003 declares them now" - вече не ги обявява; точно това върнахте.
  • „control_hash NOT NULL" - 0007 изрично го оставя NULL-ируем и обяснява защо.
  • „Rebuild-based" - вече е тригерна; заглавието на самата миграция обяснява защо пресъздаването е грешният път.

Последното е най-неприятно: коментарът обещава на следващия четец, че повторното прилагане пресъздава таблицата - тоест точно операцията, която миграцията отхвърля като опасна. А „0003 declares them now" ще прати същия четец обратно да добавя CHECK-ове в 0003.

Само коментар е, но е в единствения файл, който се изпълнява срещу производството.

Дребно

Заварената форма в теста за паритет е ръчно написана и се разминава с живата таблица на две места - bidder_id е без външния ключ, а match_method е без NOT NULL DEFAULT 'exact_name_key'. Нито едно от двете не докосва гейта, тъй че изводът не се клати; ако някога решите да я сверите буквално, живата дефиниция се вади с SELECT sql FROM sqlite_master WHERE name='interest_links'.

Обобщение

Съдържателно PR-ът е готов. Остава коментарът и номерът на миграцията - #307 също носи 0006 и 0007.

…ара за 0010

Преномериране: midt-bg#307 взе 0006/0007, midt-bg#308 взе 0008, тъй че тези две се местят на
0009 и 0010. Обновени са всички препратки - тестове, deploy.yml,
related-persons-data.yml, scripts/cacbg/load.mjs, docs/deploy.md.

Коментарът над прилагането на 0010 в deploy.yml беше останал от предишния
замисъл и твърдеше три неверни неща: че 0003 обявявала CHECK-овете (тя е
върната непокътната и не обявява нищо), че се слагало control_hash NOT NULL
(остава NULL-ируема нарочно - регистърът я пропуска за част от декларациите,
а индексът по естествен ключ ги сгъва с COALESCE) и че миграцията е
„rebuild-based" (вече е тригерна; заглавието ѝ обяснява защо пресъздаването е
опасно - оголва външните ключове и обира доказателствените печати).

Последното беше най-неприятно: обещаваше на следващия четец точно операцията,
която самата миграция отхвърля, а „0003 declares them now" щеше да го прати
обратно да добавя CHECK-ове в приложена миграция.

docs/deploy.md вече изброява и стъпките, които сондират таблицата и добавят
само липсващите колони, за да не изглежда, че --file е единственият механизъм.

db 424 зелени, typecheck и prettier чисти, scripts/tr 134/134.
Преномерирането смени пътищата, но остави имената migration6Path/migration7Path,
което е подвеждащо и се сблъсква с едноименните променливи, които midt-bg#307 и midt-bg#308
въведоха за 0006/0007/0008.
Разрешени конфликти след midt-bg#307, midt-bg#310 и midt-bg#308.

Всички са от един и същ вид: и двете страни добавяха своя миграция към
веригата, с която тестът строи схемата, и понякога под едно и също име на
променлива. Сега всеки тест, който сервира amendments или пуска refresh-slice/
normalize-raw, прилага цялата верига - 0006/0007 (стойност на анекса),
0008 (провенанс) и 0009 (доказателствен печат). Иначе всеки набор пада на
липсваща таблица или колона от другия.

Пълният суит е зелен: db 474, web 493, ingest 84, etl 20, shared 45, config 10.
Typecheck и prettier чисти.
@todorkolev

Copy link
Copy Markdown
Collaborator

Направих двете неща от моя страна и ги подадох в клона ти (90c2f99f), без пренаписване на историята.

1. Коментарът над 0010 в deploy.yml

Пренаписан. Сега казва, че 0003 нарочно не ги обявява и защо, че прилагането е с тригери, а не с пресъздаване на таблицата (и защо пресъздаването е опасно - оголва външните ключове и обира печатите), и че control_hash остава NULL-ируема по замисъл. Добавих и че идемпотентността е проверена с три последователни прилагания.

docs/deploy.md също изброява вече и стъпките, които сондират таблицата и добавят само липсващите колони, за да не изглежда, че --file е единственият механизъм.

2. Преномериране

0006/00070009/0010. #307 взе 0006/0007, #308 взе 0008 - и двата вече са в main.

Отделно преименувах и променливите в тестовете (migration6Pathmigration9Path и т.н.). Преномерирането беше сменило само пътищата, а имената оставаха migration6Path - подвеждащо, и на всичкото отгоре се сблъскваше с едноименните променливи, които #307/#308 въведоха.

3. Сливане с main

Слях main (вече с #307, #310 и #308). Всички конфликти бяха от един и същ вид: всеки PR добавяше своята миграция към веригата, с която тестът строи схемата, тъй че след сливането всеки набор падаше на липсваща таблица или колона от другия. Сега всеки тест, който сервира amendments или пуска refresh-slice/normalize-raw, прилага цялата верига.

Зелено: db 474, web 493, ingest 84, etl 20, shared 45, config 10. Typecheck и prettier чисти.

От моя страна PR-ът е готов - чакам само CI.

Единственият конфликт е познатият: новият тест на midt-bg#304 строи схемата без 0009,
а refresh-slice.sql чете interest_link_evidence. Добавена е миграцията в
неговата верига.

db 487, web 493, ingest 84, etl 20, shared 45, config 10. Typecheck и prettier
чисти.

@todorkolev todorkolev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Одобрявам на 215123de.

Блокерът е затворен по-добре, отколкото предложих: 0003 остава непокътната (сверих я и срещу main, и срещу живата таблица на stage - съвпада дума по дума), а гейтът се носи от тригери в 0010, които важат и за новоизградена, и за заварена база. Прилагах 0010 три пъти подред - гейтът още хапе след третото, което има значение, защото deploy.yml я пуска при всеки деплой.

Тестът за паритет е истински: строи двете форми и ги държи на едни и същи откази и приемания, включително пътя през UPDATE, който никой CHECK върху заварена таблица не би покрил.

Клеветническата част я проверих в предишен кръг - тритокенната порта, съвпадението в рамките на един запис с два независими разделителя, латиницата като несъвпадение, затвореният речник на matched_fact и отделният вид document_uncorroborated, който не носи нито роля, нито факт.

Моите поправки в клона: коментарът за 0010, преномерирането на 0009/0010, преименуването на променливите в тестовете и двете сливания с main.

Зелено: db 487, web 493, ingest 84, etl 20, shared 45, config 10.

@todorkolev
todorkolev merged commit acfcdce into midt-bg:main Aug 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants