Skip to content

fix(tr,cacbg): 429 е изстиване, не спиране — и присъдите преживяват хода - #323

Merged
todorkolev merged 10 commits into
midt-bg:mainfrom
ydimitrof:fix/tr-rate-limit-and-verdict-cache
Aug 20, 2026
Merged

fix(tr,cacbg): 429 е изстиване, не спиране — и присъдите преживяват хода#323
todorkolev merged 10 commits into
midt-bg:mainfrom
ydimitrof:fix/tr-rate-limit-and-verdict-cache

Conversation

@ydimitrof

Copy link
Copy Markdown
Contributor

Какво

Конвейерът за свързани лица не е завършвал ход в CI нито веднъж, откакто ADR-0033 влезе. Причината е премерена, не предположена — и е двойна: ограничителят на ТР се е затегнал далеч под това, което ADR-0033 записа, а дизайнът не може да напредва на части.

Closes #279 (регистърната част). Заменя контекст 4 и хигиената на решение 7 на ADR-0033, и твърдението „всеки ход, който решава, трябва и да обхожда" на ADR-0034.

Находката

Премерено на 2026-08-19 през httpsGet на scripts/tr/client.mjs — тоест през самия производствен транспорт:

Наблюдение Стойност
Заявки преди блока 5; шестата връща 429
Обхват по IP, не по сесия — нова EPZEUSessionID също получава 429
Вид 0 байта, без Retry-After, без X-RateLimit-*; 48–67 ms срещу 250–475 ms за истински акт → отказан на ръба
Второ лице на +60 s връзката увисва (20 s изтичане) вместо да отговори 429
Възстановяване още блокирано на +60 s, чисто на +161 s

Изпълнение 32154394308 пада идентично — на шестата заявка, TR cache: 5/537 covered. Прагът не е особеност на изпълнителя на GitHub.

ADR-0033 записа „429 при ~50 натрупани заявки" и траен блок. И двете вече не са верни: прагът е 5, а блокът се чисти сам за под три минути. Оттам: 537 кандидата ÷ 5 на прозорец × ~176 s ≈ 5,3 часа при timeout-minutes: 300 — а понеже кешът не преживява изпълнителя, всеки ход тръгва от cached 0. Незавършимо при какъвто и да е темп.

Поправката

Промяна Защо
429 → изстиване (fetch-deeds.mjs) 180 s, после същият ЕИК отново, без да се отбелязва нищо. Три изстивания без успех още приключват с изход 2. Чакането на ограничител, който се чисти сам, иска по-малко от регистъра, не повече.
Увисването е блок След изстиване следващ опит е един, не TRIES_PER_EIK — иначе всяко увисване харчи пет × 20 s в увиснала връзка (тестът заковава 4 заявки вместо 16).
--max-runtime-min Ходът спира чисто между два ЕИК. Неопитан кандидат не е „неразрешен"; но кандидат, за когото сме питали и сме го загубили, още дава изход 1.
Присъди, не актове (ADR-0037) Обхождането решава до акта и го изтрива веднага. Кешът пази kind, роля, отпратка към вписване и булеви — нито едно име на трето лице.
Постепенен гейт Отказът при частичен кеш е стеснен до студен старт; иначе гейтът за монотонност на §8 поема задължението, което вече имаше.
CI Кешът с присъди се възстановява и записва между ходовете; concurrency вече не е по среда — регистърът е общият ресурс, не базата.

politeTrGet не се променя: докладва 429 като RateLimitError и не го слага в бюджета за повторения. Мести се само кой решава колко да се чака.

Проверка на живо срещу регистъра

Не опипване на прага (спец. §3.3 го забранява и ADR-0036 не разрешава повече) — а проверка, че нашият клиент се държи правилно срещу него. Цена: ~10 заявки, един умишлен блок.

[+   0s] candidates 8 · invalid 0 · cached 0 · to fetch 8 · links 8
[+  16s]   175074752: rate limited — cooling down 180s (1/3), then re-requesting the same ЕИК
[+ 203s] EXIT CODE: 0
  • Изстиване и продължаване: 5 взети → блок на шестата → 180 s → продължи → 8/8, изход 0. Старият код би спрял на 5 с изход 2.
  • Суровите актове изчезват: scratch/tr/deeds/ е празна след хода, при 8 присъди в кеша.
  • Нула имена: сканиран е всеки байт на кеша, включително свободните страници. Останаха два кирилски низа — управител (оказа се коментар в собствената ми SQL схема, в sqlite_master) и СОФИЯ (seat_normalized, разрешено по решение 3 на ADR-0010). Нула имена на лица от пет истински акта.
  • Пълен кеш = 0 заявки.
  • Обезсилване: сменен declaredSeats на една връзка → cached 7 · to fetch 1, поискан точно 121396123.

Проверка на здравия разум, че стълбата наистина е вървяла: Български пощи ЕАДbar_joint_stock, което е вярно.

Какво това НЕ доказва: пътят load.mjsinterest_links от край до край (иска корпуса на ЦАИС; покрит е от тестовете) и CI обвързването (иска workflow_dispatch срещу staging). Това са двете дупки, които един staging ход ще затвори.

Тестове

  • scripts лента: 273 теста зелени
  • pnpm test: 6/6 задачи, 487 теста в @sigma/db
  • pnpm check:docs и prettier чисти

Заслужава да се отбележат три неща, които работата извади:

  1. reconcileTermination е втора зависима от акта присъда. Без акт тя пада към terminated — тоест сваля жива връзка, вместо да я задържи. Вече се смята и кешира до присъдата.
  2. ЕГН оградата отказваше ~7% от присъдите. inputs_hash е sha256 дайджест, а самостоятелен десетцифрен низ се среща в ~7% от дайджестите — същото измерване, което вече освобождава bodySha256. Без освобождаване това би изпускало една връзка на четиринайсет, тихо. Оградата го хвана; има регресионен тест.
  3. Отвореният въпрос от плана се затвори обратно: link_key носи само името на декларатора — служител, когото повърхността публикува по замисъл — понеже personId се строи от декларатора дори при обхват family. Хеширане не е нужно; ADR-0037 казва проверения факт вместо предположението.

Ревю

Единствената част с експозиция е постепенният гейт в load.mjs — той решава какво се публикува. ADR-0037 и зеленият audit.mjs са контролите; струва си отделен поглед точно върху този хънк.

ADR-0033 context 4 recorded the limiter as ~50 cumulative requests followed by
a sustained block, and decision 7's crawl hygiene ("a 429 is an instruction to
STOP") rests on that. Re-measured on 2026-08-19 through client.mjs's own
httpsGet — the production transport, same UA, same headers, so the result
transfers:

  budget      5 requests; the 6th returns 429
  scope       IP, not session — a fresh EPZEUSessionID is refused too
  shape       0 bytes, no Retry-After, no X-RateLimit-*, 48-67ms vs 250-475ms
              for a real deed, so refused at an edge before the application
  second face while blocked the connection may STALL (20s timeout), not 429
  recovery    still blocked at +60s, clear at +161s

CI run 32154394308 fails identically — at request 6, `TR cache: 5/537 covered`
— so the threshold is not a property of GitHub's runners.

Both halves of the old premise are false: the trip point is 5, not ~50, and the
block is not sustained. That changes what the design can achieve: 537 candidates
at 5 per ~176s cycle is ~5.3h against timeout-minutes: 300, and since the deed
cache deliberately does not survive the runner, every run restarts at `cached 0`.
No pacing value completes 537 lookups in one job.

ADR-0036 records the measurements and rules that a 429 is a cooldown, not a stop.
ADR-0037 takes the way out that ADR-0034 named and left on the table: the crawl
emits per-(link, ЕИК) verdicts — a role, an entry reference and booleans, no
third-party name — so only those cross a run boundary and progress becomes
durable.

client.mjs keeps its behaviour exactly: it still reports the 429 and never folds
it into the retry budget. Only the justification changes, and the wait-and-resume
policy lands in fetch-deeds.mjs, where "how long to wait" belongs.

No code paths change in this commit.
…n one 429

The block clears in ~161s (ADR-0036), so ending a whole crawl on the first 429
threw away the run for a wall that was about to come down on its own. In CI that
meant five deeds out of 537, every month.

`fetchOne` now waits RATE_LIMIT_COOLDOWN_MS (180s) and re-requests the SAME ЕИК,
recording nothing about it in between — the block is a fact about us, not about
that company. Three cooldowns without a success still end the run with exit 2, so
a genuinely sustained block stays distinguishable from the ordinary 5-per-window
rhythm, and the counter resets on any success so a slow healthy crawl never
approaches it.

The block has a second face: while it holds, the connection stalls rather than
answering 429. Before the first cooldown we cannot know we are blocked, so a
network fault keeps the documented TRIES_PER_EIK budget; once we do know, further
attempts get exactly one try and any throw reads as "still blocked". Otherwise
each stall costs five 20s attempts fed into a tarpit — the new test pins the
difference at 4 requests rather than 16.

`--max-runtime-min` lets a run stop cleanly between two ЕИК when its wall-clock
budget is spent. Candidates never attempted are not counted as unresolved, but a
candidate we asked about and lost still exits 1 — a deadline must not launder a
real failure into a green run. This is only safe because progress is cached; the
verdict cache that makes it safe across runs lands next (ADR-0037).

politeTrGet is untouched. It still reports the 429 and never folds it into the
retry budget; only who decides how long to wait has moved.

scripts/tr: 141 tests pass, 255 across the whole scripts lane.
…roduced it

The deed cannot cross a run boundary — it carries the names of co-owners and
managers who hold no public office (ADR-0033 decision 5) — so every run has been
restarting at `cached 0` and re-spending the register's budget on the same first
few ЕИК. The decision that deed produces carries none of that: a kind, a role, an
entry reference and booleans. ADR-0037; the `verdicts` table is where it lives.

`verdictInputsHash` is the invalidation, and it refuses an evidenceVerdict
argument it does not recognise rather than hashing a subset. The failure mode of
a missed input is silent — a stale decision about a real person, published — so
adding an input now fails the run until someone decides where it belongs. Arrays
are sorted before hashing because declaredSeats arrives from a Set spread, and
insertion order must not read as a change.

A verdict is stale three ways, each of which must re-decide: the rules moved
(RULES_VERSION), the declaration moved (inputs_hash), or the lookup simply aged.
Coverage and pending are computed over LINKS rather than ЕИК, since one company
carries several and a rules bump invalidates them independently of when its deed
was last seen.

The ЕГН rail screens verdict rows too — this is the row that crosses a boundary,
which makes it the surface most worth screening, not least. purgeExpired ages
verdicts on the same clock: not as a privacy rail here, but because a published
claim must not outlive the lookup it rests on.

ADR-0037 said the family-scope link key might carry the relative's name and would
be hashed if so. Verified during implementation: personId is built from the
DECLARANT, who is the official even for a family-scope stake, and the relative is
never named. Replaced the conditional with the checked fact.

scripts/tr: 150 tests pass.
…mmediately

`--links-file` carries the declaration side of each decision, so the crawler can
compute evidenceVerdict at the moment it holds the deed rather than handing the
deed onward to a decision step that must not receive it. The raw body is deleted
as soon as it has served its purpose; the end-of-job rm -rf stays as the backstop
but no longer has to be the only thing standing between a failure and a deed left
on disk.

`--eiks-file` still works and still only fetches. The two are mutually exclusive:
one names companies, the other names decisions, and quietly treating a links file
as a candidate list would fetch deeds nothing ever decides.

The pending set is computed over LINKS in links mode. Deed freshness alone would
skip a company whose deed is fine but whose declaration moved, or whose rules
version was bumped, or that gained a link since the last run — three silent ways
to publish yesterday's answer. A company carrying several links is still one
request.

An outside-ТР answer is a decision, not an absence of one, so 404 and the empty
200 both write verdicts. Otherwise every run re-asks the register a question it
has already answered — which at 5 requests per window is the expensive kind of
mistake.

A link whose evidence cannot be read is left WITHOUT a verdict rather than given a
false one: an absent verdict re-pends the ЕИК next run, while a fabricated
"unknown" would cache a hold nothing ever revisits.

The test that keeps ADR-0037 honest asserts a co-owner who holds no public office
appears in no column of the verdict row and nowhere in the cache file — against a
verdict pinned to `document`/publishable/role=owner, so it cannot pass by
degrading to a null "unknown".

scripts lane: 271 tests pass.
…surface

The decision pass no longer touches a deed — it cannot, since ADR-0037 has the
crawler delete it the moment it has decided. evidenceVerdict and
reconcileTermination are no longer imported here at all; load.mjs reads what they
concluded, keyed on link_key and validated against rules_version and inputs_hash.

reconcileTermination turned out to be a SECOND deed-dependent decision, and a
dangerous one to lose: with no deed it falls to `terminated`, which WITHDRAWS a
live link rather than holding it — a silent recall regression. It is now computed
and cached beside the verdict.

The coverage gate was all-or-nothing because a crawl was. Under the measured
limiter (ADR-0036) that gate never opens at all, so it is scoped down to the case
where refusing is the only protection there is: a first published run, where §8's
monotonicity gate has no prior surface to compare against. Everywhere else the
monotonicity gate takes the duty it already had — it hard-fails on a published
claim that vanished, with the rules_version escape for a deliberate bump. Two
gates for one duty was the redundancy.

The sealed lookup date is now per link, from the verdict's own decided_at. A
global MAX(fetched_at) was honest while a crawl was atomic; once it is
incremental that would stamp this run's date onto a decision reached weeks ago
and overstate the freshness the methodology page promises.

One splitLinkRecord defines what a link record is, because the crawler and the
loader hashing even slightly different objects would miss every cache entry and
silently re-crawl the whole register — a five-hour bill, not an error. The
unknown-input guard caught exactly that drift while wiring this up.

It also caught a bug worth naming: inputsHash is a sha256 digest, and the ЕГН
rail refused any digest carrying a standalone ten-digit run — ~7% of them, the
same measurement that already exempts bodySha256. Unexempted it would have
dropped one verdict in fourteen at random. Not a loud failure; a recall hole
spread evenly across the surface.

The load fixtures now run the REAL decideLinks over their deeds (tr-fixture.mjs)
rather than hand-written verdict rows, so these tests still exercise the evidence
ladder end to end.

scripts lane: 273 tests pass. check:docs clean.
…register

The verdict cache is restored and saved across runs, which is the change that
makes the monthly job finishable at all: at 5 requests per window (ADR-0036) a
crawl starting from `cached 0` cannot cover 537 links in one job, and until now
every run started exactly there. Only scratch/tr/tr-cache.sqlite is cached — the
raw deeds are never uploaded and are deleted as soon as the crawler has decided,
so ADR-0033 decision 5's retention promise still covers everything it covered.

Saved on always(), because a run cut short by the limiter or the runtime budget
is precisely the one whose progress must survive, and also the one most likely to
fail a later step.

The crawl now takes --links-file and a --max-runtime-min (default 180), leaving
room for resolve/audit/ship inside the 300-minute ceiling. Spending the budget is
not a failure — the next run continues.

The concurrency group drops the environment key. Two writes to different D1 slots
cannot collide, but both crawl the SAME public register, so a staging and a
production run in parallel doubled the effective request rate against it. The
shared resource is the register, not the database.

Also corrected three rationale comments that described the design as it was: the
crawl does emit verdicts now, "decide without crawling" is a normal state rather
than an impossible one, and a daily cadence is no longer blocked by the design —
only by the cost to the register, which is a separate argument nobody has made.

scripts lane: 273 tests pass. check:docs and prettier clean.
…e the review's gaps

Review on #3 found that the machine shipped with parameters it
could never converge under. Verified independently rather than taken on trust:
at 5 requests per ~195s cooldown cycle a 180-minute budget buys ~277 lookups
against 537 candidates, and with --max-age-days 30 on a monthly cron the whole
cache expires between runs. Coverage oscillates near 50% and never reaches the
95% floor, so the pipeline would never have published at all. Worse, the queue
sorted lexicographically by ЕИК while the crawl consumes from the front, so it
was always the SAME ~277 — the tail never decided, then purged, then counted as
a monotonicity regression.

Convergence is now one condition rather than three unrelated constants:

  queue      never-decided first, then oldest decided_at — a bounded run drains
             a rotating set instead of re-serving one prefix
  retention  split from the deeds' 35 days into its own 45. The deed number is a
             privacy obligation (ADR-0033 decision 5); the verdict number is a
             freshness policy, and sharing one constant deleted verdicts faster
             than a budget-bounded crawl could refresh them.

The cadence half lands in the workflow commit.

A refused LINK no longer counts as an unreachable ЕИК. It did, which made one
deterministically unparseable deed exit 1 → the workflow stop before load.mjs →
the same failure every run, for ever. The old load.mjs said exactly this before
the code moved; the protection did not move with it.

The loader hand-rolled the verdict-currency check in two places instead of
calling verdictIsCurrent. Both copies could be deleted with all 273 tests green —
on the last fail-closed check before publishing a claim about a named person.
One definition now, and a test that seeds an older rules_version and watches the
run refuse.

upsertVerdict enforces the closed vocabulary at the boundary the row actually
crosses. The seal gate in load.mjs runs a month later, on a row that has already
travelled between runs; the schema's promise of "a ROLE, never the person" was
checked only by the eventual reader. The fixture, meanwhile, stored
matchedFact: 'name' — a value production cannot emit.

The verdict floor applies to every run, not only a first one. Keyed on "has
anything ever been published", a single leftover row switched a 95% floor off
entirely. Affordable because the loader's currency test ignores AGE deliberately:
a verdict past its refresh window is still publishable, so steady state is ~100%
and the two ways to fall below are the two where refusing is right.

Also: a corrupt restored cache is quarantined instead of being re-saved under a
newer key and preferred for ever; an empty-200 negative needs the second
observation its own measurement cites, and stays pending until it gets one; the
cooldown constants are pinned rather than tautologically followed; short_name and
latin_in_name are reported instead of written and never read; .tmp orphans are
swept; the dead verdictCoverage import is gone; and the one test that mutated
shared state to make its point is replaced by a stronger one that does not.

Mutation-checked: reverting the queue order, and deleting the rules_version
comparison, each now fail tests that previously passed.

scripts lane: 285 tests pass.
…code again

The cadence is the other half of the convergence fix. Monthly, every verdict
expired between runs, so one run faced all ~537 candidates and its budget bought
~277 — coverage oscillated near half and never crossed the floor. Weekly does not
ask the register for more: a link comes due once its verdict passes
--max-age-days either way, so the register still sees each company about monthly.
Only the bunching changes — ~135 due per run against ~277 capacity, with headroom
for a cold start to finish in two.

That reasoning is now in ADR-0037 as an explicit convergence condition with the
arithmetic, replacing the claim it made without checking: "cold start reaches
full coverage in a few runs" was false under the constants it shipped with. The
condition is pinned by a test rather than asserted in prose — its absence is
exactly how a non-converging design got this far.

Three documents asserted behaviour the code no longer has:

- docs/spec/related-persons-foundation.md still recorded Phase-0's "the block is
  sustained… the crawler ends the run, marks nothing". Every clause of that is
  measured false; it now states the re-measured behaviour and points at ADR-0036,
  and the retention bullet reflects that deeds are no longer written to disk at
  all on the decision path.
- ADR-0034 still said the coverage gate refuses a partial cache. It carries a
  status-line back-pointer and an in-place note now, and its index row says it is
  amended — the convention the reviewer noted was being skipped for the third
  time.
- The workflow promised "~20 minutes" for a pass now bounded by a 180-minute
  wall-clock budget, and asserted "every run that decides must also crawl" a few
  lines above the note saying the opposite.

Not adopted, and argued rather than skipped: an `Amended by:` field in the ADR
template (a repo-wide convention change, so its own change), a CI mechanism
binding RULES_VERSION to the ladder's content (machinery guarding a one-line
discipline, and it would fire on comment edits), and a re-measurement procedure
in ADR-0036 (process documentation for a hypothetical, in a record whose job is
to state what was measured).

scripts lane: 285 pass. pnpm test: 6/6. check:docs and prettier clean.
@cefothe

cefothe commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Code review — fix(tr,cacbg): 429 е изстиване, не спиране

Verdict: APPROVE (composite 8.5/10) · security scan CLEAN. Reviewed on the branch by four parallel specialists (security/privacy, correctness, tests, architecture/CI). No critical, high, or blocking issues — everything below is low-severity observability/doc nits.

Security-critical gate: PASS

  • Zero hardcoded secrets, no new external URLs, no eval/child_process in runtime code.
  • ALTER TABLE verdicts ADD COLUMN ${col} ${type}col/type come only from the module constant VERDICT_ADDED_COLUMNS (cache.mjs:74); all row values parameterized. No injection.
  • execFileSync appears only in a test.
  • Workflow inputs passed via env: + quoted shell vars (not inline ${{ }}), validated by posInt. No command injection.

Privacy (the crux): VERIFIED CLEAN — 9/10

The core claim holds under inspection: only booleans/roles/references cross the run boundary, no third-party name or ЕГН. Enforced by write-time rails, not comments:

  • verdicts schema has no name-capable column; matched_fact bounded by MATCHED_FACT_RE, registry_role/kind enum-checked at write (cache.mjs:330–359).
  • ЕГН screen /(?<!\d)\d{10}(?!\d)/ runs on every verdict field by exclusion; the sha256-digest exemption (inputsHash/bodySha256) is scoped by field name, not pattern — cannot leak to a data field.
  • On --links-file the raw deed is never written to disk (fetch-deeds.mjs:469), removing the deletion race entirely. Only tr-cache.sqlite is cached.
  • Corrupt restored cache is quarantined via PRAGMA integrity_check before trust (cache.mjs:83–120).
  • Low/optional: the restore-keys: tr-verdicts- cache is integrity-checked, not authenticated — mitigated by downstream inputs_hash/rules_version revalidation + D1/monotonicity gates.

Correctness — 7/10

Investigated and self-retracted the 404-guard, deadline-count, and Set-order concerns (all correct by construction). Confirmed sound: the cooldown/stall state machine, exit-code semantics (--max-runtime-min stops between ЕИКs; attempted-and-lost still exits 1), inputs_hash determinism, single-source splitLinkRecord, cached reconcileTermination, queue rotation, two-observation empty-200 rule. Remaining items are observability nits:

  • [Medium] load.mjs logs ЕИК-level trCoverage ("537/537 covered") right before a link-level 95% verdict-floor refusal — two different denominators, neither labeled distinctly. Suggest TR deeds: vs TR verdicts:.
  • [Medium] purgeExpired returns a verdicts count that the fetch-deeds.mjs:555 log condition never checks — a verdict purge (→ coverage drop) is invisible. Add || purged.verdicts.
  • [Low] Cold-start refusal doesn't distinguish outside_tr_pending (awaiting 2nd observation) links from never-crawled ones — log the breakdown.

Tests — 9/10

  • 108/108 PR-touched tests pass. The only 2 lane failures are pre-existing and out of scope (audit.test.mjs, extract-companies.test.mjsERR_MODULE_NOT_FOUND from earlier merge feat(tr,db,web): регистрово доказателство вместо отличителност на името (#279) #309, confirmed via git diff; not touched here).
  • Both mutation claims independently reproduced: reverting queue order fails pendingVerdictEiks rotates by staleness; deleting the rules_version comparison fails two tests (unit + integration).
  • All seven key behaviors have non-vacuous, anti-cheater assertions (stall pins at 4 not 16 requests; co-owner absent from every column and the raw sqlite blob; convergence loop reaches 9/9 then makes 0 requests).
  • Low gaps: openCache corruption-quarantine + schema-migration branches, and recon_* column round-trip, lack direct unit tests (the latter has integration coverage via tr-fixture.mjs).

Architecture / CI / Docs — 9/10

  • Decision logic cleanly moved to a single source (decideLinks); load.mjs only reads. Key-drift guarded by shared splitLinkRecord + fail-closed verdictInputsHash.
  • Cache save/restore ("unique write + prefix restore") correct; global concurrency group correctly identifies the register (not D1) as the shared resource.
  • Convergence arithmetic a prior draft got wrong is now an executable test (retention 45 > max-age 30 + cadence 7).
  • ADR-0034 amendment back-pointers, README index, and spec are all internally consistent; no doc reasserts the old behavior.
  • [Low] scripts/tr/cache.mjs:16 — stale header comment still says "a 429 ends the run," the exact model ADR-0036 overturns, in a file this PR rewrites. One-line fix.
  • [Info] Capacity headroom is ~2× (277 cap vs ~135 due/run); fine now, but a candidate-set growth could silently cross it since the test guards the ratio, not the absolute ceiling.

Recommended (all non-blocking)

  1. Fix stale comment cache.mjs:16.
  2. Distinguish the two coverage logs (TR deeds: vs TR verdicts:) in load.mjs.
  3. Log purged-verdict count (fetch-deeds.mjs:555).

Net: a well-engineered, atomic, privacy-sound change with load-bearing tests. Nothing blocks merge.

🤖 Parallel multi-agent review via /code-review-pr.

…ng the old design

Second review (cefothe, midt-bg#323) — APPROVE, security clean, three low nits. All
three verified real, plus a fourth found next to one of them.

purgeExpired has returned a verdict count since verdicts got their own retention
window, but the log condition never checked it. A purged verdict DROPS coverage,
and coverage is what the 95% floor refuses on — so the one event that best
explains a surprising refusal next run was the one event that never printed.

The two coverage lines in load.mjs sit next to each other and count different
things: `TR cache:` was ЕИК-denominated, `TR verdicts:` link-denominated, and the
refusal that follows is about links. Reading "537/537 covered" immediately above
"REFUSE TO LOAD" invites exactly the wrong conclusion. Now `TR deeds:` and `TR
verdicts:`.

Two comments still described the design this PR replaced: cache.mjs's header said
a 429 ends the run — the model ADR-0036 overturns, in a file this PR rewrites —
and the purge comment still credited a monthly refresh for keeping rows inside
their window.

No behaviour changes beyond the added log line.

scripts lane: 285 pass. check:docs and prettier clean.
@ydimitrof
ydimitrof marked this pull request as ready for review August 19, 2026 19:12
@ydimitrof

Copy link
Copy Markdown
Contributor Author

@cefothe — благодаря за прегледа, и особено за трите самооттегляния: находка, която е била проверена и отхвърлена, спестява точно толкова време, колкото и находка, която стои.

Проверих и трите бележки срещу кода, преди да ги приема, и трите са верни. Поправени в 32b3c88, плюс една четвърта, която стоеше до №3.

1. Броят изтрити присъди не се логваше. Това беше най-полезната от трите и я взимам не като nit. purgeExpired връща verdicts откакто присъдите имат собствен прозорец на задържане, но условието на лога не го гледаше — а изтрита присъда сваля покритието, и точно покритието е това, върху което подът от 95% отказва. Тоест единственото събитие, което най-добре обяснява изненадващ отказ на следващия ход, беше единственото, което не се отпечатваше.

2. Двата реда за покритие броят различни неща. TR cache: беше по ЕИК, TR verdicts: по връзки, а отказът след тях е за връзки. „537/537 covered" непосредствено над „REFUSE TO LOAD" води точно към грешния извод. Сега са TR deeds: и TR verdicts:.

3. cache.mjs:16 твърдеше „a 429 ends the run" — моделът, който ADR-0036 оборва, във файл, който този PR пренаписва. 4. А коментарът до поправка №1 още кредитираше месечното опресняване за това, че редовете стоят в прозореца си — каденцата стана седмична в същия PR.

Извън обхвата, но си струва да се каже: PR-ът беше в режим „чернова" (convert_to_draft в 07:55Z, малко след създаването му). Одобренията ти и на @nikimilenkov не можеха да броят за нищо, докато е така. Маркиран е готов за ревю; reviewDecision още е празно, защото и двата прегледа дойдоха като COMMENTED, а не като формално Approve.

По бележката ти за запаса от капацитет — права е и я записвам, без да я поправям тук: тестът за сходимост пази съотношението (задържане > max-age + каденца), не абсолютния таван (~277 справки на ход). Растеж на кандидатския набор би минал тихо през него. Смятам, че поправката е тест върху абсолютния таван, а не още една константа — но това е отделна промяна с отделен аргумент, не притурка към този PR.

285 теста в scripts лентата, check:docs и prettier чисти. Без промяна в поведението извън добавения ред в лога.

@cefothe

cefothe commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Review — fix(tr,cacbg): 429 is a cooldown, not a stop

Recommendation: APPROVE (2 non-blocking cleanup nits). Reviewed across privacy, correctness, tests, and CI/ADR consistency by parallel specialized reviewers; findings verified against code, not the description. All CI checks green.

Phase 0 — security gate: ✅ CLEAN

No hardcoded secrets, no new/modified URLs, no eval/exec/child_process in production code, no SQL string-interpolation of external input.

The critical gate — data privacy: ✅ CLEAN (verified in code)

The central claim — the verdict cache crosses the run boundary carrying zero third-party PII — holds:

  • Schema structurally cannot hold a name (cache.mjs:50-70): every non-boolean column is a code, date, hash, or closed-vocabulary token.
  • Enforced at write, not only read: upsertVerdict rejects any kind/registry_role/matched_fact outside a sealed vocabulary and runs an anchored ЕГН-shape guard over every field. The privacy invariant is now checked by the writer at the boundary — a genuine improvement over the prior design.
  • Raw deeds are never written to disk on the CI path (fetch-deeds.mjs:469 guards the write behind if (!linksFile)) — decided in memory, discarded per-iteration; even a mid-loop crash persists nothing. Local runs purge in a finally.
  • CI caches only scratch/tr/tr-cache.sqlite, no upload-artifact, actions SHA-pinned, secrets via env bindings with name-only guards.

Correctness: ✅ Sound

  • Cooldown: MAX_COOLDOWNS=3 → 4 total attempts (1 initial 429 + 3 post-cooldown), test-pinned. A hung connection during a cooldown gets one retry, not TRIES_PER_EIK (the "4 not 16" behavior), asserted at fetch-deeds.test.mjs:240.
  • Budget check placed at top of loop (before pace-sleep and request). Untried candidate → exit 0; attempted-and-lost → exit 1 even with budget remaining. Exit codes 0/1/2 correct and matched by CI's exit-2-as-warning handling.
  • Convergence encoded as an executable invariant (VERDICT_RETENTION 45 > MAX_AGE 30 + CADENCE 7), not just prose. Weekly cadence + 45-day retention closes the oscillation gap the monthly cadence had.

Tests: ✅ Strong — 87/87 pass

Every changed test file was run green. Not cheater tests — the "0 names in cache" test greps the raw sqlite bytes for a co-owner's name; the budget test uses a paired control run so the clock can't launder a pass; a deliberate anti-tautology guard pins the cooldown constant to the measurement.

CI & ADRs: ✅ Consistent

Cache uses the standard unique-key + restore-keys-prefix pattern (persists a mutating cache correctly); if: always() save survives a budgeted stop; global concurrency with cancel-in-progress: false correctly serializes against the shared IP-scoped register; all referenced script flags exist. ADR-0034 supersession, README index, and the spec all match the new ADR-0036/0037.

Non-blocking findings

# Sev File Issue
1 Low-Med scripts/cacbg/load.mjs:671 trCache opened at top level but trCache.close() runs on only one refuse path (:728). The success path (:1191 closes only db) and any exception between 671–1191 leak the handle. Benign (Node closes on process exit) but misses the resource-cleanup standard. Fix: wrap 671→1191 in try/finally { trCache.close(); }.
2 Low scripts/tr/cache.mjs:643-645 SELECT COUNT(*) then DELETE outside a transaction — a concurrent insert between them mis-counts the returned number (log cosmetic only; deletion is correct). Use DELETE ... RETURNING or wrap in BEGIN IMMEDIATE.

Minor coverage gaps (informational): openCache corrupt-file quarantine path and the idempotent ALTER TABLE migration aren't directly exercised; the decided===0 && refused===0 diagnostic branch is untested (no exit-code effect).

Neither finding blocks merge.

Third review (cefothe, midt-bg#323) — APPROVE, security clean, two low nits plus three
branches listed as informational coverage gaps. Both nits verified against the
code before being taken. One of the three gaps cannot honestly be tested, and
now says so instead.

load.mjs opened the verdict cache and closed it on one exit of three. The
verdict-floor refusal closed the pair; the suppression-gate throw and the success
path closed only `db`. Nothing observable followed — the workflow saves the cache
BEFORE load.mjs runs, so an uncheckpointed WAL cannot reach the saved artifact —
but this file closes `db` explicitly before every exit it has, and an intent kept
on two paths out of three is worse than either alternative. Not the reviewer's
try/finally: it would reindent ~520 untouched lines for a low nit.

purgeExpired counted the verdicts it was about to delete with a SELECT COUNT(*),
then deleted them. The reviewer's stated reason does not apply here — DatabaseSync
is single-process and synchronous, and the workflow serialises runs against the
register — but the count was already available from the DELETE. Two statements
asking one question can only agree or be wrong; one statement cannot disagree
with itself.

Three tests for branches nothing exercised, each mutation-checked rather than
merely green: openCache's quarantine of a corrupt restore, and of a file that is
valid sqlite but not this cache — only the table probe catches the second, since
integrity_check passes it — and the idempotent ALTER TABLE migration, whose
load-bearing claim is that a restored older cache keeps its rows. That older
shape is synthesised by dropping the two columns from the current schema, so the
fixture cannot drift from SCHEMA when a column is next added.

The third gap gets a comment, not a test. `decided === 0 && refused === 0` is
unreachable: readLinksFile normalises every link.eik through safeEik, and the
queue is derived from those same links, so every ЕИК reaching it has a link that
matches exactly. A test would have to fabricate a state the wiring cannot
produce. It is not dead code either — the queue compares safeEik(link.eik) where
decideLinks compares link.eik raw — so it stays, with the reason written down.

No behaviour changes.

scripts lane: 288 pass; plain lane 91. check:docs and prettier clean.
@ydimitrof

Copy link
Copy Markdown
Contributor Author

@cefothe — и двете бележки са верни; проверих ги срещу кода, преди да ги приема. Поправени в bc5de5d, заедно с трите празнини в покритието — с едно изключение, което обяснявам отдолу.

1. load.mjs:671 — кешът се затваряше на един изход от три. Отказът по пода за присъди затваряше двойката; хвърлянето на гейта за заглушавания (:1018) и успешният път (:1191) затваряха само db.

Нищо наблюдаемо не следваше от това: работният поток запазва кеша на стъпка :323, преди load.mjs да тръгне на :331, така че незаписан WAL не може да стигне до запазения артефакт. Но този файл затваря db изрично преди всеки свой изход — намерението вече е там и е спазено на два пътя от три, което е по-лошо от всяка от двете последователни възможности.

Не с try/finally, както предлагаш: това преформатира ~520 иначе недокоснати реда заради дребна бележка, върху PR, който вече е голям.

2. cache.mjs:643SELECT COUNT(*), после DELETE. Причината, която посочваш, не важи тук: DatabaseSync е едно-процесен и синхронен, а глобалната concurrency група с cancel-in-progress: false сериализира ходовете срещу регистъра — конкурентен запис между двете изявления няма откъде да дойде.

Поправката обаче е правилна по по-проста причина: броят вече го връща самият DELETE. Две изявления, задаващи един въпрос, могат само да се съгласят или да сгрешат; едно изявление не може да си противоречи. Затова run().changes, а не DELETE ... RETURNING (материализира всеки изтрит ред, за да ги преброи) и не BEGIN IMMEDIATE (транзакция там, където стига едно изявление). Проверено, че на node:sqlite changes се връща като число, не като BigInt.

Празнините в покритието. Две от трите станаха тестове, и трите са мутационно проверени, а не просто зелени:

Ако се върне Пада
cacheIsUsable → винаги true двата теста за карантина
цикълът ALTER TABLE тестът за миграцията
verdicts.changes0 verdicts age out on their OWN clock (съществуващият, непроменен)

Тестът за „валиден sqlite, но не този кеш" сваля таблицата deeds от истински кеш, вместо да сглоби файл на ръка — така пада точно върху пробата на таблицата, защото PRAGMA integrity_check минава такъв файл. Тестът за миграцията синтезира предишната форма, като сваля двете колони от текущата схема, вместо да преписва старото CREATE TABLE: преписана фикстура се разминава със SCHEMA в мига, в който се добави колона, и спира да тества миграцията, заради която е писана. Контролът, че здрав кеш не се поставя под карантина, вече съществуваше — cache.test.mjs:58.

Третата празнина получава коментар, не тест — и това е несъгласие, не пропуск. decided === 0 && refused === 0 е недостижим: readLinksFile:175 нормализира всеки link.eik през safeEik на границата на файла, а pendingVerdictEiks:471 извежда опашката от същите тези връзки. Значи всеки ЕИК, който стига дотам, има поне една връзка, съвпадаща точно. Тест би трябвало да фабрикува състояние, което окабеляването не може да произведе, а това е определението за празен тест.

Не е и мъртъв код: клонът пази реална асиметрия — опашката сравнява safeEik(link.eik), докато decideLinks:196 сравнява суровия link.eik !== eik. Днес readLinksFile ги прави еднакви; извикващ, който сглоби връзки без нея, не би решил нищо и би мълчал за това. Затова остава, с причината записана над него, за да не се чете „недостижим" като „нетестван".

Една поправка към прегледа, извън обхвата: двете предварително съществуващи повреди в лентата, които отбелязваш (audit.test.mjs, extract-companies.test.mjs, ERR_MODULE_NOT_FOUND от #309), вече не се възпроизвеждат — лентата е 288/288.

288 теста в scripts лентата, 91 в обикновената, check:docs и prettier чисти. Без промяна в поведението.

@cefothe

cefothe commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Code review — parallel multi-agent pass

Recommendation: APPROVE (9.5/10). No blocking issues. All CI checks green (check, test, cacbg, semgrep). Verified against the files, not just the diff.

Phase 0 — security-critical scan: CLEAN

No hardcoded secrets. actions/cache@55cc834… pinned to full SHA. TR_HOST unchanged. No pull_request trigger (scheduled/dispatch only), so no lower-trust context can write the tr-verdicts- cache namespace.

The one "critical" flag — investigated and dismissed

fetchOne classifies any throw as "blocked" once cooldowns > 0. On first read this looks like it could launder a network transient into an exit-2 block. It does not, and it is the documented design:

  • cooldowns only becomes >0 after a confirmed RateLimitError on the same ЕИК (the first iteration requires err instanceof RateLimitError); a network fault on the first attempt correctly falls through to the breaker.
  • Treating a post-429 stall as "still blocked" is the deliberate ADR-0036 "the block has two faces" heuristic (a stall surfaces only as a 20s timeout — the tarpit).
  • The obvious "fix" (err instanceof RateLimitError only) would defeat stall detection — don't apply it.
  • Impact of a mis-class is favorable in CI anyway: exit 2 → warning + resumable, vs exit 1 → failed build.

Confirmed sound (highest-stakes areas)

  • Privacy rail holds in depth. Every verdict column is guarded at write: kindVERDICT_KINDS, registry_role{owner,manager}, matched_fact via isSealedFact (role→field-code, seat→≤2 tokens), recon_label∈closed enum, assertNoEgnShape on all non-exempt columns. link_key embeds only the declarant (public official), never a family relative or a co-owner. Logs print ЕИК, never link_key. The inputsHash ЕГН-exemption is a sha256 digest — correct.
  • Cache-key consistency: crawler (readLinksFile) and loader (linkRecordFor) both route through the single splitLinkRecord, so no hash drift and no silent full re-crawl. HASHED_INPUTS covers all 7 declaration-side args to evidenceVerdict, structurally enforced (unknown key → throw).
  • Convergence is a test, not prose: repeated bounded runs reach full coverage and hold it at zero requests; VERDICT_RETENTION_DAYS 45 > max-age 30 + cadence 7; the oldest-first queue prevents prefix starvation.
  • Workflow wiring, cache paths, WAL, exit codes, runtime-budget plumbing all match end-to-end (WAL folds in on db.close() before the separate save step).
  • Corrupt-cache quarantine, two-observation outside_tr promotion, .tmp-<pid> orphan sweep, and sqlite handle cleanup on all normal paths.

Minor / optional (non-blocking)

  1. Lowentry_number / entry_date rest only on assertNoEgnShape, without the positive-vocabulary guard the other sealed columns carry. Hardening: /^\d+$/ and an ISO-date shape check in upsertVerdict, matching the writer-side-promise pattern already applied to matched_fact.
  2. Lowload.mjs openCache(TR_CACHE_DB) isn't wrapped in try/finally; a throw there (a quarantine rename failure) leaks the already-open db. Trivial — the process exits anyway.
  3. Doc nit — the cooldown cycle is quoted as 176s in ADR-0036 (from the measured ~161s) but ~195s in ADR-0037 and the workflow comment (from the shipped 180s constant). The convergence conclusion (277 > 135) holds under either; a one-line reconciling note would spare a reader diffing the two.
  4. Operational — the global concurrency group with cancel-in-progress: false means a long scheduled staging run can queue a manual production ship behind it for hours. Intended (never cancel a crawl mid-flight), but worth a runbook line.

Reviewed with a security-auditor + code-analyzer + workflow/docs pass in parallel; findings independently verified before posting.

@todorkolev
todorkolev merged commit 016fb1f into midt-bg:main Aug 20, 2026
5 checks passed
@ydimitrof
ydimitrof deleted the fix/tr-rate-limit-and-verdict-cache branch August 21, 2026 09:45
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Aug 21, 2026
…urrent upstream

Periodic heartbeat sync — brings in upstream changes since 2026-08-16
(PR midt-bg#177 head ed0eb60 was last rebased then): midt-bg#313, midt-bg#314, midt-bg#323.

The integration-test lane and OTEL store polyfills do not overlap with
the upstream changes (related-persons, undici bump, cacbg crawl-deadline
fix, TR rate-limit remeasurement), so a clean merge is expected. PR
midt-bg#177's only / touch was the original
inScope commit, which is independent of upstream deps.
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