diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index dbee8724..f01f1b8a 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -24,7 +24,7 @@
## Чеклист
-- [ ] Комитите следват [conventional commits](https://www.conventionalcommits.org) и **нямат** `Co-Authored-By:` trailer
+- [ ] Комитите следват [conventional commits](https://www.conventionalcommits.org) и **нямат** `Co-Authored-By:` trailer, който сочи към агент (Claude Code, Codex, Cursor, Copilot). Трейлъри с **хора** са наред и не се махат — те са начинът заслугата на сътрудника да оцелее при squash
- [ ] PR-ът е с **един логически обхват** и е от форк към `midt-bg/sigma:main`
- [ ] `pnpm typecheck` минава
- [ ] `pnpm test` (поне за засегнатите пакети) минава
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9cebb18a..b4a7f739 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,7 +30,7 @@ jobs:
container:
image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Static analysis (semgrep)
run: semgrep scan --config p/security-audit --config p/secrets --config p/typescript --metrics=off --error
@@ -55,7 +55,7 @@ jobs:
tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks
"$RUNNER_TEMP/gitleaks" dir . --redact --no-banner
- - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2c3f42ae..2ed7a02a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -83,7 +83,7 @@ jobs:
SIGMA_VECTORIZE_NAME: ${{ vars.SIGMA_VECTORIZE_NAME }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
@@ -145,6 +145,26 @@ jobs:
pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0003_related_persons_foundation.sql
+ # 0009 attaches the Trade Register evidence seal (#279, ADR-0033). Both migrations are
+ # applied by name here because wrangler's migration ledger is empty on this D1 (the base
+ # schema was created out-of-band), so `d1 migrations apply` would collide on 0000.
+ pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
+ --config wrangler.deploy.jsonc --remote --yes \
+ --file ../../packages/db/migrations/0009_interest_link_evidence.sql
+ # 0010 owns the publishing-gate enforcement for EVERY database (#279 §2) — fresh and already
+ # deployed alike. It is deliberately NOT declared in 0003: that migration is already applied
+ # everywhere, `CREATE TABLE IF NOT EXISTS` never revisits an existing table, and the ship step
+ # wipes ROWS, not definitions — so an in-place CHECK would exist only on databases built after
+ # the edit, and be absent on exactly the database that serves the site. Enforced with BEFORE
+ # INSERT/UPDATE triggers, NOT a table rebuild: the create-copy-drop-rename route would expose
+ # the foreign keys and strip every evidence seal, emptying the public surface until the next
+ # monthly run (see the migration header). `control_hash` stays NULLABLE by design — the register
+ # omits it on some declarations; the natural-key index folds those with COALESCE instead.
+ # Idempotent: every statement is `IF NOT EXISTS` over a converging definition, so re-applying it
+ # on each deploy is a no-op. Verified by three consecutive applications, gate still enforcing.
+ pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
+ --config wrangler.deploy.jsonc --remote --yes \
+ --file ../../packages/db/migrations/0010_publishing_gate_constraints.sql
# The base schema was created out-of-band with `d1 execute --file`, so wrangler's migration
# ledger is empty and `d1 migrations apply` would collide on 0000. Probe the actual table
# instead. SQLite has no `ADD COLUMN IF NOT EXISTS`; a completion-marker table is created only
@@ -214,6 +234,78 @@ jobs:
;;
esac
+ # #305 additive columns (migrations 0006/0007) must exist BEFORE the Worker serves: the contract-page
+ # query reads amendments.value_restated/value_suspect, and promote-amendments/refresh-slice also INSERT
+ # value_treatment — so all three must be present or the read AND the ETL write fail. Same rationale as
+ # the currency step above: the migration ledger is empty, so `d1 migrations apply` would collide on
+ # 0000. Probe the actual table and ALTER only the missing columns (SQLite has no ADD COLUMN IF NOT
+ # EXISTS). These are pure additions with safe defaults (INTEGER NOT NULL DEFAULT 0 / nullable TEXT), so
+ # no backfill or completion marker is needed — an ALTER populates every existing row. Malformed
+ # responses are fatal. This also makes 0006/0007 replay-safe when they were applied out-of-ledger.
+ # #306 folds in here rather than adding a second step of its own (as PR #308's own note asked):
+ # refresh-slice.sql and promote-amendments.sql write contract_number_raw + link_method into served
+ # `amendments`, so the first cron after release would crash on the missing columns. One probe, one
+ # mechanism — a second hand-written step is another chance for the alias bug #310 had to fix.
+ - name: Apply amendment restated/suspect + provenance columns
+ if: steps.guard.outputs.ok == 'true'
+ run: |
+ node scripts/wrangler-render.mjs apps/web/wrangler.jsonc
+ # Each alias below MUST be the column name itself: read_flag looks the row up by the very string
+ # ensure_column is called with. An alias that merely describes the column (has_restated) makes
+ # `Object.hasOwn(row, key)` false for every column, which the probe treats as an unreadable answer
+ # and turns into a hard failure — so the step could never succeed, on any database.
+ schema_json="$(pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
+ --config wrangler.deploy.jsonc --remote --yes --json \
+ --command "SELECT
+ (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name = 'value_restated') AS value_restated,
+ (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name = 'value_treatment') AS value_treatment,
+ (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name = 'value_suspect') AS value_suspect,
+ (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name = 'contract_number_raw') AS contract_number_raw,
+ (SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name = 'link_method') AS link_method")"
+
+ read_flag() {
+ printf '%s' "$schema_json" | node -e '
+ const fs = require("fs");
+ let payload;
+ try {
+ payload = JSON.parse(fs.readFileSync(0, "utf8"));
+ } catch {
+ process.exit(2);
+ }
+ const result = Array.isArray(payload) ? payload[0] : payload;
+ const row = result && Array.isArray(result.results) ? result.results[0] : null;
+ const key = process.argv[1];
+ if (!row || !Object.hasOwn(row, key)) process.exit(2);
+ process.exit(Number(row[key]) === 1 ? 0 : 1);
+ ' "$1"
+ }
+
+ add_column() {
+ echo "amendments.$1 missing; adding it."
+ pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
+ --config wrangler.deploy.jsonc --remote --yes \
+ --command "ALTER TABLE amendments ADD COLUMN $2"
+ }
+
+ ensure_column() {
+ set +e
+ read_flag "$1"
+ status="$?"
+ set -e
+ case "$status" in
+ 0) echo "amendments.$1 already exists." ;;
+ 1) add_column "$1" "$2" ;;
+ *) echo "::error::Could not determine whether amendments.$1 exists."; exit 1 ;;
+ esac
+ }
+
+ ensure_column value_restated "value_restated INTEGER NOT NULL DEFAULT 0"
+ ensure_column value_treatment "value_treatment TEXT"
+ ensure_column value_suspect "value_suspect INTEGER NOT NULL DEFAULT 0"
+ # #306 provenance: NULL on both = the row linked by contract_number directly (or is unlinked).
+ ensure_column contract_number_raw "contract_number_raw TEXT"
+ ensure_column link_method "link_method TEXT"
+
# `run deploy`, not `deploy` — bare `pnpm deploy` is a pnpm built-in, not our package script.
- name: Deploy explorer (sigma)
if: steps.guard.outputs.ok == 'true'
diff --git a/.github/workflows/related-persons-data.yml b/.github/workflows/related-persons-data.yml
index deb24884..2beb2706 100644
--- a/.github/workflows/related-persons-data.yml
+++ b/.github/workflows/related-persons-data.yml
@@ -16,17 +16,63 @@ name: Related-persons data foundation (build + ship)
# XML (fetch.mjs skips files already on disk) — it fills gaps, not a from-scratch rebuild unless the cache is cleared.
on:
+ # MONTHLY, and deliberately on the EXISTING workflow rather than a second one. #279 §9 asks for the
+ # publishing decisions to run on a cadence; duplicating the job would create a second ship path with
+ # its own copy of the credential guards, the D1-target guard and the ship floor — and the copy is the
+ # one nobody exercises.
+ #
+ # WHY MONTHLY AND NOT DAILY, which is what §9 and ADR-0033 originally describe. The decision and its
+ # rejected alternatives are recorded in ADR-0034, which supersedes ADR-0033's cadence line — this
+ # comment is the operational summary, not the record, so a reader who disagrees has somewhere to argue.
+ # A decision cannot be recomputed
+ # without the raw deeds — evidenceVerdict's strongest rung matches the declarant's name against the
+ # register's own text — and the raw deeds must not be persisted between runs: they carry the names and
+ # addresses of co-owners and managers who hold no public office, and an Actions cache entry lives on
+ # GitHub's storage under a restore-keys chain where ADR-0033 decision 5's 35-day retention cannot reach
+ # it. So the deeds live and die with one runner, and every run that decides must also crawl. A daily
+ # decision would therefore mean ~400 daily requests against somebody else's register, which is what
+ # spec §3.3 exists to prevent — so the cadence follows the deeds, not the other way round.
+ #
+ # Restoring a daily decision needs the crawl to emit the per-(link, ЕИК) match verdict, so only
+ # booleans cross a run boundary and nothing has to hold a name. That is a design change, not a
+ # schedule change, and it is deliberately not folded in here.
+ #
+ # A scheduled run takes the same path as a manual one, with two differences:
+ # • it targets STAGING (the environment default below). Production stays manual, so the
+ # assertD1TargetAuthorized guard keeps a prod write a deliberate act.
+ # • `full_crawl` is empty and therefore false, so it never crawls the CACBG corpus — it re-runs the
+ # decisions over the cached corpus, which is exactly what #279 §9 means by a pure, zero-network
+ # decision. Registry lookups are the one exception and run from inside this job on the 1st of the
+ # month (see „Decide whether to hit the register"): they cannot be a separate workflow, because the
+ # raw deeds they fetch carry third-party names and must not be persisted between runners.
+ # If the corpus cache has been evicted, extract yields nothing, the ship floor refuses, and the run
+ # fails loudly rather than wiping the surface.
+ schedule:
+ # 03:00 UTC on the 1st. See the note above: the decision and the crawl are one pass, so the decision
+ # cadence is the crawl cadence — #279 §9's monthly registry rhythm.
+ - cron: '0 3 1 * *'
workflow_dispatch:
inputs:
environment:
description: Target D1 environment
type: choice
- options: [dev, staging, production]
- default: dev
+ # No `dev` environment exists — every fallback in this file is `|| 'staging'` and the note above
+ # says the job targets staging. A `dev` default sent a manual dispatch to an environment with no
+ # secrets, so it failed at the credential guard rather than running where it was meant to.
+ options: [staging, production]
+ default: staging
full_crawl:
description: Crawl the CACBG corpus (slow). RESUMES from the cached raw XML — fetch.mjs skips files already on disk, so this fills gaps rather than re-fetching from scratch (clear the cache for a true rebuild). Off = extract from the existing raw cache without crawling.
type: boolean
default: false
+ tr_max_age_days:
+ description: Re-fetch deeds older than this many days (default 30). Use a large value to force a full refresh.
+ type: string
+ default: '30'
+ tr_limit:
+ description: Stop after this many registry lookups (blank = all pending). Bound a first run while watching for a 429.
+ type: string
+ default: ''
# Least-privilege: this workflow only reads the repo; it authenticates to Cloudflare with a scoped token.
permissions:
@@ -34,19 +80,20 @@ permissions:
concurrency:
# Never let two data-foundation writes to the same environment overlap; do not cancel one in flight.
- group: related-persons-data-${{ inputs.environment }}
+ group: related-persons-data-${{ inputs.environment || 'staging' }}
cancel-in-progress: false
jobs:
build-and-ship:
runs-on: ubuntu-latest
# A from-scratch corpus crawl of the whole register is the long pole (all year-folders, politely
- # throttled) and must fit in ONE job with extract/resolve/audit/ship after it — 120 was too tight and
- # timed out mid-crawl. 300 leaves headroom under GitHub's 6-hour hard cap. A run past this is wedged
+ # throttled) and must fit in ONE job with the registry lookups, extract/resolve/audit/ship after it —
+ # 120 was too tight and timed out mid-crawl. The Търговски регистър pass adds ~20 minutes on top
+ # (~400 candidates at 1 request / 3 s). 300 leaves headroom under GitHub's 6-hour hard cap. A run past this is wedged
# (stalled gov-server I/O) — fail rather than burn the slot. The raw corpus is cached (below) so a
# re-run resumes instead of re-crawling from scratch.
timeout-minutes: 300
- environment: ${{ inputs.environment }}
+ environment: ${{ inputs.environment || 'staging' }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -54,16 +101,16 @@ jobs:
SIGMA_D1_NAME: ${{ vars.SIGMA_D1_NAME }}
# The DECLARED intended environment — the independent anchor the ship guard checks the (name, id) pair
# against (T48), so a consistent-but-wrong pair can't wipe the wrong DB. See assertD1TargetAuthorized.
- SIGMA_SHIP_ENV: ${{ inputs.environment }}
+ SIGMA_SHIP_ENV: ${{ inputs.environment || 'staging' }}
# Suppression (takedown) list keying — load.mjs fingerprints link-suppressions.jsonl under this salt and
# refuses to build if the list is non-empty but the salt is unset (fail-closed, ADR-0031). Without this
# the first suppression entry would break every ship; the key version guards a coordinated salt rotation.
SUPPRESSION_SALT: ${{ secrets.SUPPRESSION_SALT }}
SUPPRESSION_KEY_VERSION: ${{ vars.SUPPRESSION_KEY_VERSION || '1' }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24 # node:sqlite (DatabaseSync) + native TS type-stripping, no flags
cache: pnpm
@@ -87,20 +134,25 @@ jobs:
# live in each Environment's SIGMA_D1_NAME var; the ship step additionally requires SIGMA_SHIP_ENV to match
# and PROVES the name↔SIGMA_D1_ID pair against Cloudflare before deleting — assertD1TargetAuthorized.)
# The slot set is kept in lockstep with PRODUCTION_SLOTS in scripts/ship-related-persons.mjs (#226).
+ # $SIGMA_SHIP_ENV, never `${{ inputs.environment }}` inline. `type: choice` is enforced by the UI
+ # only — a POST to .../dispatches can pass an arbitrary string, which an inline expression pastes
+ # into the shell before `[` ever evaluates it. This block is also the one that decides whether the
+ # production guard fires, so a crafted value could both inject and walk past the guard it was
+ # meant to trip. The env binding above passes the same value as data, where it cannot be code.
- name: Guard — SIGMA_D1_NAME matches the environment
run: |
case "${SIGMA_D1_NAME}" in
sigma-blue|sigma-green) IS_PROD_SLOT=1 ;;
*) IS_PROD_SLOT=0 ;;
esac
- if [ "${{ inputs.environment }}" = "production" ]; then
+ if [ "${SIGMA_SHIP_ENV}" = "production" ]; then
if [ "$IS_PROD_SLOT" != "1" ]; then
echo "::error::production requires SIGMA_D1_NAME to be a prod slot (sigma-blue|sigma-green), got '${SIGMA_D1_NAME}' — refusing to ship."
exit 1
fi
else
if [ "$IS_PROD_SLOT" = "1" ]; then
- echo "::error::SIGMA_D1_NAME='${SIGMA_D1_NAME}' (a PRODUCTION slot) on a '${{ inputs.environment }}' run — a rebuild would WIPE prod. Set a non-production SIGMA_D1_NAME variable for this Environment."
+ echo "::error::SIGMA_D1_NAME='${SIGMA_D1_NAME}' (a PRODUCTION slot) on a '${SIGMA_SHIP_ENV}' run — a rebuild would WIPE prod. Set a non-production SIGMA_D1_NAME variable for this Environment."
exit 1
fi
fi
@@ -112,7 +164,7 @@ jobs:
# (Cross-run only — a hard mid-crawl job timeout can't save; durable incremental persistence is the
# sigma-etl Worker's R2 job per ADR-0006. GitHub's 10 GB repo cache is the ceiling here.)
- name: Restore raw CACBG corpus cache
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: scratch/cacbg/raw
key: cacbg-raw-${{ github.run_id }}
@@ -129,7 +181,7 @@ jobs:
# run resumes from here. Skipped on full_crawl=false runs (nothing new was fetched).
- name: Save raw CACBG corpus cache
if: ${{ always() && inputs.full_crawl }}
- uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: scratch/cacbg/raw
key: cacbg-raw-${{ github.run_id }}
@@ -151,11 +203,29 @@ jobs:
work="$GITHUB_WORKSPACE/data/work"
mkdir -p "$work"
rm -f "$work/eop.sql" "$work/backfill.sqlite"
+ # interest_links + interest_link_evidence come along too, and NOT for the resolver's benefit —
+ # load.mjs drops and rebuilds them. They are the input to the monotonicity gate: load.mjs
+ # exports the currently-published set BEFORE the wipe, and audit.mjs fails the run on a link
+ # that was published last time, under an unchanged rules_version, and is not published now.
+ # Without them the work DB starts with no interest_links at all, the pre-wipe export reads
+ # „no such table", the snapshot is [] on every run, and the gate protects nothing.
+ # They are absent on a first run (0003/0006 not yet applied), which is why they are exported
+ # separately and tolerated as missing rather than folded into the parity loop below.
pnpm --filter @sigma/web exec wrangler d1 export "$SIGMA_D1_NAME" \
--config wrangler.deploy.jsonc --remote -y \
--table bidders --table contracts --table tenders --table authorities \
--output "$work/eop.sql"
sqlite3 "$work/backfill.sqlite" < "$work/eop.sql"
+ if pnpm --filter @sigma/web exec wrangler d1 export "$SIGMA_D1_NAME" \
+ --config wrangler.deploy.jsonc --remote -y \
+ --table interest_links --table interest_link_evidence \
+ --output "$work/prior-links.sql"; then
+ sqlite3 "$work/backfill.sqlite" < "$work/prior-links.sql"
+ echo " prior surface: $(sqlite3 "$work/backfill.sqlite" \
+ "SELECT COUNT(*) FROM interest_links WHERE status='published';") published link(s)"
+ else
+ echo "::notice::No prior interest_links on this D1 (first run) — the monotonicity gate has no baseline yet."
+ fi
for t in bidders contracts tenders authorities; do
local_n=$(sqlite3 "$work/backfill.sqlite" "SELECT COUNT(*) FROM $t;")
remote_json=$(pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
@@ -169,9 +239,60 @@ jobs:
fi
done
+ # ── Trade Register lookups ────────────────────────────────────────────────────────────────────
+ # In THIS job, deliberately, and not in a workflow of their own. The decision run needs the raw
+ # deeds (evidenceVerdict matches declarant names against the register's own text, which is exactly
+ # the data the index refuses to store), and those deeds carry the names and addresses of co-owners
+ # and managers who hold no public office. Handing them between runners means persisting them —
+ # an Actions cache entry survives on GitHub's storage under a restore-keys chain, where the 35-day
+ # retention ADR-0033 decision 5 promises cannot reach it. Same runner, same job, deleted with it.
+ #
+ # The previous split also could not work on its own terms: the crawl job gated on a candidate list
+ # only the decision job produced, the decision job refused without a cache only the crawl job
+ # produced, and neither persisted its half. Both were permanently green no-ops.
+ - name: Emit the candidate ЕИК list for the crawler
+ run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/load.mjs --emit-candidates
+
+ # ALWAYS, on every run that reaches here — not conditionally. The deeds do not survive the runner,
+ # so „decide without crawling" is not a state this job can be in: load.mjs's coverage gate below
+ # would refuse the empty cache and the run would fail. Skipping the crawl is only meaningful once
+ # the crawl emits verdicts rather than deeds (see the note on the schedule above).
+ #
+ # Pace and refusals live in scripts/tr/fetch-deeds.mjs and are not configurable here on purpose:
+ # 1 request / 3 s, sequential, a closed candidate set, and a 429 ends the run without marking
+ # anything. Spec §3.3 permits a bounded per-ЕИК lookup and forbids bulk scraping; the limiter is
+ # the operator's only way to state a rate preference, so we do not tune around it.
+ - name: Refresh the Trade Register deed cache
+ env:
+ MAX_AGE: ${{ inputs.tr_max_age_days || '30' }}
+ LIMIT: ${{ inputs.tr_limit }}
+ run: |
+ set -euo pipefail
+ ARGS=(--eiks-file scratch/cacbg/staging/candidate-eiks.txt --max-age-days "$MAX_AGE")
+ [ -n "$LIMIT" ] && ARGS+=(--limit "$LIMIT")
+ set +e
+ node scripts/tr/fetch-deeds.mjs "${ARGS[@]}"
+ code=$?
+ set -e
+ # Exit 2 is the rate limiter, and it is NOT a build failure: the run stopped politely, marked
+ # nothing, and the partial cache is valid. It is not a licence to publish either — load.mjs's
+ # coverage gate below refuses a partial cache on its own, which is where that decision belongs.
+ if [ "$code" -eq 2 ]; then
+ echo "::warning::The register rate-limited us; the crawl stopped and the cache is resumable."
+ exit 0
+ fi
+ exit "$code"
+
- name: Resolve → build domain in a work sqlite (libel gate must pass)
run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/load.mjs
+ # The raw deeds are third-party personal data and exist only to produce booleans. They are deleted
+ # with the runner regardless; deleting them explicitly means a failure AFTER this point cannot
+ # leave them sitting in a job artifact or a debug upload someone adds later.
+ - name: Delete the raw deeds
+ if: always()
+ run: rm -rf scratch/tr/deeds
+
- name: Audit published links (fails the run on any libel-invariant violation)
run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/audit.mjs
@@ -185,6 +306,20 @@ jobs:
pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0003_related_persons_foundation.sql
+ # 0006 attaches the Trade Register evidence seal (#279, ADR-0033). Both migrations are
+ # applied by name here because wrangler's migration ledger is empty on this D1 (the base
+ # schema was created out-of-band), so `d1 migrations apply` would collide on 0000.
+ pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
+ --config wrangler.deploy.jsonc --remote --yes \
+ --file ../../packages/db/migrations/0009_interest_link_evidence.sql
+ # 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`
+ # never revisits an existing table and the ship step wipes ROWS, not definitions — so without
+ # this a live database keeps the unconstrained shape no matter how often the data reloads.
+ # Rebuild-based and therefore idempotent: re-applying copies an already-constrained table.
+ pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
+ --config wrangler.deploy.jsonc --remote --yes \
+ --file ../../packages/db/migrations/0010_publishing_gate_constraints.sql
- name: Ship свързани-лица tables → D1
# ship-related-persons.mjs shells out to `wrangler d1 execute` with cwd=apps/web. Two things it
@@ -198,7 +333,14 @@ jobs:
node scripts/wrangler-render.mjs apps/web/wrangler.jsonc
cp apps/web/wrangler.deploy.jsonc apps/web/wrangler.jsonc
export PATH="$GITHUB_WORKSPACE/apps/web/node_modules/.bin:$GITHUB_WORKSPACE/node_modules/.bin:$PATH"
- node scripts/ship-related-persons.mjs --work-db data/work/backfill.sqlite --remote --yes
+ # --min-links is passed EXPLICITLY, not left at its default of 50. Under #279 the surface is
+ # measured at ~329 links, so 50 no longer protects anything: a partially-restored Trade
+ # Register cache yields roughly 80 published links, which clears a floor of 50, ships a
+ # decimated surface and wipes the rest. The loader's coverage gate is the first defence and
+ # this is the second (ADR-0033 decision 7). Lower it deliberately, in a PR, if a genuinely
+ # smaller surface is ever expected.
+ node scripts/ship-related-persons.mjs --work-db data/work/backfill.sqlite --remote --yes \
+ --min-links 250
- name: Reindex search projection → officials searchable (same batch the cron runs)
# ship writes ONLY the свързани-лица domain tables (interest_links, persons, …), NOT search_index.
diff --git a/.github/workflows/scripts-test.yml b/.github/workflows/scripts-test.yml
index 7ee3ef4c..61deb9f7 100644
--- a/.github/workflows/scripts-test.yml
+++ b/.github/workflows/scripts-test.yml
@@ -20,31 +20,36 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# node:test only — the scripts under test import nothing outside node built-ins and each other,
- # so no `pnpm install` is required.
+ # so no `pnpm install` is required. The one external dependency is the `sqlite3` binary, which
+ # ship-e2e.test.mjs drives as the real target its fake wrangler writes into; it ships with the
+ # ubuntu-latest image, and the test fails loudly (never skips) if it is ever absent.
- run: node --test scripts/*.test.mjs
- # свързани-лица CACBG pipeline tests (parse/classify/load/audit/tr-census/extract). These use
- # node:sqlite (DatabaseSync) and import the shared companyNameKey .ts via the register-ts resolve
- # hook, so they need Node 24 (node:sqlite + native TS type-stripping, no experimental flag) rather
- # than the Node 22 above. The libel-critical resolution logic gates merges here.
+ # свързани-лица pipeline tests — the CACBG leg (parse/classify/load/audit/extract) and the
+ # Търговски регистър leg (scripts/tr: ЕИК checksum, HTTP client, deed cache). These use node:sqlite
+ # (DatabaseSync) and import the shared companyNameKey .ts via the register-ts resolve hook, so they
+ # need Node 24 (node:sqlite + native TS type-stripping, no experimental flag) rather than the Node 22
+ # above. The libel-critical resolution logic gates merges here.
cacbg:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
# parse.mjs imports fast-xml-parser (a workspace dep), so this job needs node_modules — unlike
# the plain-node scripts above. Install before the scraper tests run.
- run: pnpm install --frozen-lockfile
- - run: node --import ./scripts/cacbg/register-ts.mjs --test scripts/cacbg/*.test.mjs
+ # Both legs, or the glob silently excludes a whole module: scripts/tr/*.test.mjs matched NO job
+ # until this line existed, so every test in it would have been decorative.
+ - run: node --import ./scripts/cacbg/register-ts.mjs --test scripts/cacbg/*.test.mjs scripts/tr/*.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index 8ed755cf..545ae557 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,7 +24,7 @@ No `develop`, no `staging`. Maintainers with write access work on short-lived fe
- Use [conventional commits](https://www.conventionalcommits.org): `(): `. Types: `feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`, `perf`, `style`. Subject is lowercase imperative, no trailing period.
- Use the `/smart-commit` and `/suggest-commit` skills when drafting messages. They produce the canonical format for this repo.
-- **Never include `Co-Authored-By:` trailers.** Keep the history clean; CI may grep for this.
+- **Never credit a coding agent in a `Co-Authored-By:` trailer** (Claude Code, Codex, Cursor, Copilot). Trailers naming **people** are fine and must not be stripped — GitHub generates them from the PR's commit authors on squash, and they are what keeps a contributor's credit on `main`, since the squash commit's own author is always the PR opener.
- Small, focused commits are encouraged. Commit as you go — not all at the end. Easier to review and revert. Don't mix unrelated changes in one commit.
## Pull requests
diff --git a/apps/etl/src/eop.test.ts b/apps/etl/src/eop.test.ts
index a96a50df..851ff51f 100644
--- a/apps/etl/src/eop.test.ts
+++ b/apps/etl/src/eop.test.ts
@@ -1,5 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { computeWorkerCatchupPlan, listBucketForDay } from './eop';
+import { computeWorkerCatchupPlan, listBucketForDay, stageBaseFromBucket } from './eop';
+
+/**
+ * A response whose stream is deliberately left open, so `cancel()` on the underlying source really
+ * fires. A stream that is enqueued *and closed* would report success without proving anything: the
+ * spec short-circuits `cancel()` once a stream is already closed.
+ */
+function openBodyResponse(init: ResponseInit & { url?: string }): {
+ response: Response;
+ cancelled: () => boolean;
+} {
+ let cancelled = false;
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(''));
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ const response = new Response(body, init);
+ if (init.url) Object.defineProperty(response, 'url', { value: init.url });
+ return { response, cancelled: () => cancelled };
+}
function fakeDbFromFreshness(maxLoadedDate: string): D1Database {
const db = {
@@ -53,3 +76,96 @@ describe('EOP fetch host allowlist', () => {
);
});
});
+
+describe('EOP responses the ingest walks away from', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('releases the body of a missing bucket instead of leaving the stream open', async () => {
+ const { response, cancelled } = openBodyResponse({ status: 403 });
+ vi.stubGlobal('fetch', vi.fn(async () => response) as unknown as typeof fetch);
+
+ await expect(listBucketForDay('2026-06-01')).resolves.toBeNull();
+ expect(cancelled()).toBe(true);
+ });
+
+ it('releases the body of a failed bucket listing before throwing', async () => {
+ const { response, cancelled } = openBodyResponse({ status: 500 });
+ vi.stubGlobal('fetch', vi.fn(async () => response) as unknown as typeof fetch);
+
+ await expect(listBucketForDay('2026-06-01')).rejects.toThrow(/bucket 2026-06-01: HTTP 500/);
+ expect(cancelled()).toBe(true);
+ });
+
+ it('releases the body of a blocked redirect before throwing', async () => {
+ const { response, cancelled } = openBodyResponse({
+ status: 200,
+ url: 'https://evil.example/open-data-2026-06-01/',
+ });
+ vi.stubGlobal('fetch', vi.fn(async () => response) as unknown as typeof fetch);
+
+ await expect(listBucketForDay('2026-06-01')).rejects.toThrow(/blocked redirected EOP fetch/);
+ expect(cancelled()).toBe(true);
+ });
+
+ // The one drain site the first cut of this change left uncovered: bypassing it kept the whole suite
+ // green. A blocked redirect on an OBJECT fetch is a different call path from the bucket listing.
+ it('releases the body of a redirected object fetch before throwing', async () => {
+ const { response, cancelled } = openBodyResponse({
+ status: 200,
+ url: 'https://evil.example/open-data-2026-06-01/contracts.json',
+ });
+ vi.stubGlobal('fetch', vi.fn(async () => response) as unknown as typeof fetch);
+
+ await expect(
+ stageBaseFromBucket(
+ {} as D1Database,
+ {
+ day: '2026-06-01',
+ bucketUrl: 'https://storage.eop.bg/open-data-2026-06-01/',
+ keys: { contracts: 'contracts.json' },
+ },
+ '2026-06-01T00:00:00.000Z',
+ ),
+ ).rejects.toThrow(/blocked redirected EOP fetch from storage\.eop\.bg to evil\.example/);
+ expect(cancelled()).toBe(true);
+ });
+
+ // Cancellation must be initiated, never awaited: a stream whose cancel() never settles must not be
+ // able to wedge the ingest. Before this, `await res.body.cancel()` made listBucketForDay hang forever.
+ it('does not wait for a cancel that never settles', async () => {
+ const body = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode('x'));
+ },
+ cancel() {
+ return new Promise(() => {}); // never settles
+ },
+ });
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(body, { status: 403 })) as unknown as typeof fetch,
+ );
+
+ await expect(listBucketForDay('2026-06-01')).resolves.toBeNull();
+ });
+
+ it('releases the body of a failed object fetch before throwing', async () => {
+ const { response, cancelled } = openBodyResponse({ status: 500 });
+ vi.stubGlobal('fetch', vi.fn(async () => response) as unknown as typeof fetch);
+
+ await expect(
+ stageBaseFromBucket(
+ {} as D1Database,
+ {
+ day: '2026-06-01',
+ bucketUrl: 'https://storage.eop.bg/open-data-2026-06-01/',
+ keys: { contracts: 'contracts.json' },
+ },
+ '2026-06-01T00:00:00.000Z',
+ ),
+ ).rejects.toThrow(/HTTP 500/);
+ expect(cancelled()).toBe(true);
+ });
+});
diff --git a/apps/etl/src/eop.ts b/apps/etl/src/eop.ts
index 96f63195..7a0fffab 100644
--- a/apps/etl/src/eop.ts
+++ b/apps/etl/src/eop.ts
@@ -70,14 +70,32 @@ const dayUrl = (baseUrl: string, day: string): string =>
const objectUrl = (bucketUrl: string, key: string): string =>
`${bucketUrl}${encodeURIComponent(key)}`;
-function assertAllowedFinalHost(requestUrl: string, responseUrl: string): void {
+function disallowedFinalHost(requestUrl: string, responseUrl: string): string | null {
const requested = new URL(requestUrl);
const final = new URL(responseUrl || requestUrl);
- if (final.host !== requested.host) {
- throw new Error(`blocked redirected EOP fetch from ${requested.host} to ${final.host}`);
+ if (final.host === requested.host) return null;
+ return `blocked redirected EOP fetch from ${requested.host} to ${final.host}`;
+}
+
+// A response body that is never read keeps its stream open for the rest of the invocation; the
+// collector is not a substitute for releasing it. Every path below that walks away from a response
+// without reading it - a blocked redirect, a missing bucket, any non-OK status - releases it here.
+// Deliberately NOT awaited: cancelling only needs to be INITIATED for the runtime to release the
+// stream, and awaiting it would make every caller hostage to a cancel() that never settles, which
+// is precisely the failure mode this file exists to reduce.
+function discardBody(res: Response): void {
+ try {
+ void res.body?.cancel().catch(() => {});
+ } catch {
+ // Already consumed, locked, or errored - there is nothing left to release either way.
}
}
+function releaseAndFail(res: Response, message: string): never {
+ discardBody(res);
+ throw new Error(message);
+}
+
function decodeXml(s: string): string {
return s
.replace(/</g, '<')
@@ -175,9 +193,13 @@ export async function listBucketForDay(
): Promise {
const bucketUrl = dayUrl(opts.baseUrl ?? DEFAULT_BASE_URL, day);
const res = await fetch(bucketUrl);
- assertAllowedFinalHost(bucketUrl, res.url);
- if (res.status === 403 || res.status === 404) return null;
- if (!res.ok) throw new Error(`bucket ${day}: HTTP ${res.status}`);
+ const blocked = disallowedFinalHost(bucketUrl, res.url);
+ if (blocked) return releaseAndFail(res, blocked);
+ if (res.status === 403 || res.status === 404) {
+ discardBody(res);
+ return null;
+ }
+ if (!res.ok) return releaseAndFail(res, `bucket ${day}: HTTP ${res.status}`);
const keys: BucketKeys = {};
for (const key of parseBucketKeys(await res.text())) {
@@ -189,8 +211,9 @@ export async function listBucketForDay(
async function fetchJson(url: string): Promise {
const res = await fetch(url);
- assertAllowedFinalHost(url, res.url);
- if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
+ const blocked = disallowedFinalHost(url, res.url);
+ if (blocked) return releaseAndFail(res, blocked);
+ if (!res.ok) return releaseAndFail(res, `${url}: HTTP ${res.status}`);
return res.json();
}
diff --git a/apps/etl/src/index.test.ts b/apps/etl/src/index.test.ts
index 536ef5ba..c1c62649 100644
--- a/apps/etl/src/index.test.ts
+++ b/apps/etl/src/index.test.ts
@@ -214,3 +214,57 @@ describe('RefreshWorkflow FX loading (#158)', () => {
expect(usd.amount_eur).toBeCloseTo(EXPECTED_USD_EUR, 2);
});
});
+
+// The Workers runtime logs an error on every *successful* instance of this Workflow, so the absence
+// of errors proves nothing about whether the cron actually ran. A finished refresh has to announce
+// itself — and, just as importantly, a failed one must not.
+describe('RefreshWorkflow completion signal', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ function captureLogs(): () => Record[] {
+ const lines: string[] = [];
+ vi.spyOn(console, 'log').mockImplementation((line: unknown) => {
+ lines.push(String(line));
+ });
+ return () =>
+ lines.flatMap((line) => {
+ try {
+ return [JSON.parse(line) as Record];
+ } catch {
+ return [];
+ }
+ });
+ }
+
+ it('announces a finished refresh with the run summary', async () => {
+ const db = freshServedDb();
+ stubFetchRoutes();
+ const logs = captureLogs();
+
+ const result = await runRefresh(makeWorkflow(db));
+
+ const done = logs().find((e) => e.event === 'etl_refresh_complete');
+ expect(done).toBeDefined();
+ expect(done?.staged).toBe(result.staged);
+ expect(done?.derived).toBe(result.derived);
+ expect(done?.to).toBe(TODAY);
+ });
+
+ it('stays silent when the refresh throws', async () => {
+ const db = freshServedDb();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new Error('storage.eop.bg unreachable');
+ }) as unknown as typeof fetch,
+ );
+ const logs = captureLogs();
+
+ await expect(runRefresh(makeWorkflow(db))).rejects.toThrow(/storage\.eop\.bg unreachable/);
+
+ expect(logs().some((e) => e.event === 'etl_refresh_complete')).toBe(false);
+ });
+});
diff --git a/apps/etl/src/index.ts b/apps/etl/src/index.ts
index a3ad9afc..cb352af5 100644
--- a/apps/etl/src/index.ts
+++ b/apps/etl/src/index.ts
@@ -94,6 +94,12 @@ export class RefreshWorkflow extends WorkflowEntrypoint {
let results: Awaited> = [];
let staged = 0;
let derived = 0;
+ // The runtime logs an error ("...your Worker's code had hung...") on every *successful* instance
+ // of this Workflow, at the instant run() returns - measured across runs of 5 and 30 steps, see
+ // docs/etl.md. "No errors in the dashboard" is therefore not a health signal here, so a refresh
+ // that actually finished has to say so itself. Logged from the finally, after the staging drop,
+ // so it only ever claims success for a run that survived its own cleanup.
+ let outcome: RefreshResult | null = null;
try {
await step.do('create-transient-staging', async () =>
@@ -109,7 +115,8 @@ export class RefreshWorkflow extends WorkflowEntrypoint {
if (staged === 0) {
console.warn(JSON.stringify({ level: 'warn', event: 'etl_zero_ingest', fetchedAt, plan }));
- return { ...plan, days: results.length, staged: 0, derived: 0 };
+ outcome = { ...plan, days: results.length, staged: 0, derived: 0 };
+ return outcome;
}
// FX rates BEFORE the derive (#158): the CLI paths run scripts/load-fx.mjs first, but this
@@ -180,9 +187,13 @@ export class RefreshWorkflow extends WorkflowEntrypoint {
}
});
- return { ...plan, days: results.length, staged, derived };
+ outcome = { ...plan, days: results.length, staged, derived };
+ return outcome;
} finally {
await step.do('drop-transient-staging', async () => dropTransientStaging(this.env.DB));
+ if (outcome) {
+ console.log(JSON.stringify({ level: 'info', event: 'etl_refresh_complete', ...outcome }));
+ }
}
}
}
diff --git a/apps/web/app/components/ConflictCards.tsx b/apps/web/app/components/ConflictCards.tsx
index 4bbfeba4..a716c425 100644
--- a/apps/web/app/components/ConflictCards.tsx
+++ b/apps/web/app/components/ConflictCards.tsx
@@ -20,6 +20,7 @@ import {
officialHref,
partitionContracts,
relationLabel,
+ registryEvidenceLabel,
temporalLabel,
} from '../lib/conflicts';
@@ -181,6 +182,26 @@ function ConflictCard({
)}
+ {/* The Trade Register fact the link's identity rests on (#279, ADR-0033). This is what makes
+ „every shown link explains itself" true rather than a promise: a reader can open the same act
+ we read and check it. The wording is careful — the register records a ROLE, it does not
+ certify the ownership claim, which comes from the official's own declaration. */}
+
+
Регистър
+
+
+
+ {registryEvidenceLabel(l)}
+ {l.registryEntryDate ? ` · вписване ${l.registryEntryDate}` : ''}
+ {/* The entry NUMBER, not just its date: a date does not identify a record, and this is
+ what a reader types to find the same act we read. Rendered only when present —
+ a seat/ЕИК confirmation cites no act entry, and an empty „№" would read as missing
+ data rather than as an inapplicable field. */}
+ {l.registryEntryNumber ? ` · № ${l.registryEntryNumber}` : ''}
+ {l.registryLookupDate ? ` · справка ${l.registryLookupDate}` : ''}
+
+
+
{l.contractCount > 0 && (
diff --git a/apps/web/app/lib/assistant/describe-schema.ts b/apps/web/app/lib/assistant/describe-schema.ts
index 6157b382..8e887417 100644
--- a/apps/web/app/lib/assistant/describe-schema.ts
+++ b/apps/web/app/lib/assistant/describe-schema.ts
@@ -10,11 +10,13 @@ export const DATA_TRAPS: string[] = [
'Парични агрегати: СУМИРАЙ САМО `contracts.amount_eur` (каноничен EUR, безопасен за сумиране). ' +
'НИКОГА не сумирай `contracts.amount` — то е „както е записано" в смесена валута (`currency`), само за показване.',
'Канонична база за всяка парична сума: `contracts.amount_eur IS NOT NULL`. НЕ филтрирай по ' +
- '`value_flag`: включи `ok`, `review`, `annex_suspect`, `value_low` и поправените `value_suspect` редове.',
+ '`value_flag`: включи `ok`, `review`, `annex_suspect`, `annex_total_suspect`, `value_low` и ' +
+ 'поправените `value_suspect` редове.',
'`amount_eur IS NULL` означава, че няма използваема EUR стойност (например `value_suspect` без ' +
'прогноза за поправка или чужда валута без FX курс); само тези редове се изключват от парични суми.',
- '`value_flag` ∈ {ok, review, annex_suspect, value_suspect, value_low} мени значението на стойността ' +
- 'на реда, но не и каноничната база; `date_flag` ∈ {ok, signed_after_publication} е вердикт за датата.',
+ '`value_flag` ∈ {ok, review, annex_suspect, annex_total_suspect, value_suspect, value_low} мени ' +
+ 'значението на стойността на реда, но не и каноничната база; `date_flag` ∈ {ok, ' +
+ 'signed_after_publication} е вердикт за датата.',
"`tenders.procedure_type = 'неизвестна'` маркира СИНТЕТИЧНИ (само-договорни) преписки — " +
'изключи ги при анализ на разпределението по процедура, освен ако нарочно ги искаш.',
'`lots` са на grain по обособена позиция — не ги брой едно към едно срещу `contracts`.',
diff --git a/apps/web/app/lib/conflicts.test.ts b/apps/web/app/lib/conflicts.test.ts
index a4bfd5f8..6cec490a 100644
--- a/apps/web/app/lib/conflicts.test.ts
+++ b/apps/web/app/lib/conflicts.test.ts
@@ -21,6 +21,7 @@ import {
partitionContracts,
relationLabel,
temporalLabel,
+ registryEvidenceLabel,
} from './conflicts';
function link(over: Partial = {}): ConflictLink {
@@ -43,7 +44,13 @@ function link(over: Partial = {}): ConflictLink {
contemporaneousValueEur: 40_000_000,
firstContractYear: '2021',
lastContractYear: '2024',
- sourceUrl: 'https://register.cacbg.bg/2024/i.xml',
+ sourceUrl: 'https://register.cacbg.bg/2024/x.xml',
+ // #279: a link only reaches the DTO when its identity rests on a Trade Register fact.
+ evidenceKind: 'document',
+ registryRole: 'owner',
+ registryEntryNumber: '20110502101007',
+ registryEntryDate: '2011-05-02',
+ registryLookupDate: '2026-08-05',
...over,
};
}
@@ -523,3 +530,31 @@ describe('authorityShareDisplay', () => {
});
});
});
+
+describe('registryEvidenceLabel', () => {
+ // The wording is load-bearing. The register records a ROLE; it does not certify that the official owns
+ // anything — that claim comes from their own declaration and is rendered separately. A label that said
+ // „собственик според ТР" would assert something the evidence does not support (ADR-0033 decision 2).
+ it('reports what the act records, never an ownership conclusion', () => {
+ expect(registryEvidenceLabel({ evidenceKind: 'document', registryRole: 'owner' })).toBe(
+ 'лицето е вписано като съдружник/собственик',
+ );
+ expect(registryEvidenceLabel({ evidenceKind: 'document', registryRole: 'manager' })).toBe(
+ 'лицето е вписано като управител',
+ );
+ });
+
+ it('a seat/ЕИК confirmation claims identity, not a registry role', () => {
+ // „Потвърдено" means the COMPANY was identified from something the official declared — nobody was
+ // found in the act, so the label must not imply anyone was.
+ const label = registryEvidenceLabel({ evidenceKind: 'confirmed', registryRole: null });
+ expect(label).toBe('самоличност, потвърдена по декларирани данни');
+ expect(label).not.toMatch(/вписан/);
+ });
+
+ it('never renders the word „собственик" for a mere confirmation', () => {
+ expect(registryEvidenceLabel({ evidenceKind: 'confirmed', registryRole: 'owner' })).not.toMatch(
+ /собственик/,
+ );
+ });
+});
diff --git a/apps/web/app/lib/conflicts.ts b/apps/web/app/lib/conflicts.ts
index 50a6f16b..7e0908c1 100644
--- a/apps/web/app/lib/conflicts.ts
+++ b/apps/web/app/lib/conflicts.ts
@@ -24,10 +24,45 @@ const RELATION_LABEL: Record = {
};
/** Bulgarian label for a declared relation. Unknown values pass through — never invent a stronger claim. */
+/**
+ * How the company's identity was established, in the register's own terms (#279, ADR-0033).
+ *
+ * Deliberately does NOT say the official owns anything: „вписан съдружник/собственик" reports what the
+ * act RECORDS, while the ownership claim itself comes from the official's own declaration and is
+ * rendered separately as „дялово участие". „Потвърдено" means the company was identified by a fact the
+ * official declared — the seat or the ЕИК — not that anybody was found in the act.
+ */
+export function registryEvidenceLabel(l: {
+ evidenceKind: 'document' | 'confirmed';
+ registryRole: 'owner' | 'manager' | null;
+}): string {
+ if (l.evidenceKind === 'confirmed') return 'самоличност, потвърдена по декларирани данни';
+ return l.registryRole === 'manager'
+ ? 'лицето е вписано като управител'
+ : 'лицето е вписано като съдружник/собственик';
+}
+
export function relationLabel(relation: string): string {
return RELATION_LABEL[relation] ?? relation;
}
+/**
+ * How to describe a PAGE's set of links in prose (#279 §2.6). The card labels above are already
+ * family-aware; the surrounding page copy was not, and asserted „собствен дял" — an OWN stake — above
+ * cards that correctly read „свързано лице". On a family-only page that is a false claim about the named
+ * official, and it is the second source of truth the card-label fix set out to remove.
+ *
+ * Derived from the links themselves rather than passed in, so a page cannot describe a set it isn't
+ * rendering. Mixed sets get the neutral wording: it is the only phrasing true of every card.
+ */
+export function declaredStakeNoun(links: { relation: string }[]): string {
+ const anyFamily = links.some((l) => l.relation === 'related');
+ const anySelf = links.some((l) => l.relation !== 'related');
+ if (anyFamily && !anySelf) return 'дял на свързано лице';
+ if (anyFamily && anySelf) return 'деклариран дял — собствен или на свързано лице';
+ return 'собствен дял';
+}
+
// Defense in depth: the slug is base64url and the ЕИК numeric today (so encoding is a no-op), but if either
// assumption ever drifts, an un-escaped `/`, `?` or `#` would break routing and the cache key. Escape the
// dynamic segments unconditionally (ydimitrof #226, conflicts.ts).
diff --git a/apps/web/app/routes/conflict.company.tsx b/apps/web/app/routes/conflict.company.tsx
index 9dd28595..5de8e12e 100644
--- a/apps/web/app/routes/conflict.company.tsx
+++ b/apps/web/app/routes/conflict.company.tsx
@@ -9,7 +9,7 @@ import { ConflictCards } from '../components/ConflictCards';
import { publicCache } from '../lib/cache';
import { withDbRetry } from '../lib/retry';
import { seoMeta } from '../lib/meta';
-import { companyProfileHref } from '../lib/conflicts';
+import { companyProfileHref, declaredStakeNoun } from '../lib/conflicts';
// Officials with a published declared interest in one winner (by ЕИК). Reads interest_links only. 404 when
// no official has a published link to this company — never an empty page under a company's name.
@@ -59,7 +59,7 @@ export default function ConflictCompany({ loaderData }: Route.ComponentProps) {
>
}
title={company}
- lede={`Длъжностни лица, декларирали собствен дял в това дружество пред КПКОНПИ. ${count(links.length)} ${plural(links.length, 'връзка', 'връзки')} — всяка е точно съвпадение по фирмено име.`}
+ lede={`Длъжностни лица, декларирали ${declaredStakeNoun(links)} в това дружество пред КПКОНПИ. ${count(links.length)} ${plural(links.length, 'връзка', 'връзки')} — всяка почива на проверим факт от Търговския регистър.`}
/>
diff --git a/apps/web/app/routes/conflict.methodology.tsx b/apps/web/app/routes/conflict.methodology.tsx
index 32936a30..d7b1f9d4 100644
--- a/apps/web/app/routes/conflict.methodology.tsx
+++ b/apps/web/app/routes/conflict.methodology.tsx
@@ -182,6 +182,76 @@ export default function ConflictMethodology() {
връзката е двусмислена и не се публикува — никога не отгатваме кой
от субектите е имал предвид деклараторът.
+
+
Проверка в Търговския регистър
+
+ Съвпадението по име вече не е достатъчно, за да се публикува
+ връзка. За всяко дружество правим отделна справка в{' '}
+ Търговския регистър (публична, без регистрация) и връзката се
+ показва само ако самоличността на дружеството е потвърдена от{' '}
+ проверим факт в акта. Редът е следният — прилага се първото, което
+ е изпълнено:
+
+
+
+ Акционерна форма (АД, ЕАД, КДА) — не се показва никога. Книгата
+ на акционерите не е публична, тоест твърдението е непроверимо; освен това дялът
+ може да е миноритарен или борсов, тоест несъществен.
+
+
+ Документ. И трите имена на лицето се срещат в живо поле на акта —
+ управители, съдружници, едноличен собственик или физическо лице-търговец. Пазим
+ ролята (съдружник/собственик или само управител), защото тя не е едно и също.{' '}
+
+ Съвпадението по име само по себе си не стига: изисква се и нещо извън фирменото
+ наименование да установи, че дружеството е декларираното
+ {' '}
+ — деклариран ЕИК, декларирано седалище, съвпадащо с вписаното, или достатъчно
+ отличително фирмено наименование. Иначе връзката остава скрита. Причината е, че
+ дружеството се разпознава сред изпълнителите по обществени поръчки: ако
+ лицето притежава едноименно дружество, което никога не е кандидатствало, търсенето
+ би посочило чуждото — и съименник в неговия акт би „потвърдил" връзка, невярна и в
+ двете си части.
+
+
+ Потвърдено. Лицето не е намерено в акта, но декларирано от него
+ седалище съвпада с регистрираното за този ЕИК, или самият
+ декларатор е изписал ЕИК. Тук се потвърждава{' '}
+ кое е дружеството, а не кой го притежава.
+
+
+ Оборена. Само за собствен дял: лицето не фигурира никъде в акта,
+ а най-късното вписване за собственост е преди декларирания период
+ — тоест записът покрива целия период и не назовава лицето. Връзката отпада.
+
+
+ Неизвестна — всичко останало. Остава скрита.
+
+
+
+ Какво доказва това и какво не. Регистърът потвърждава{' '}
+ самоличността на дружеството; той не удостоверява, че лицето притежава дял
+ — това твърдение идва от неговата собствена декларация. Затова на картата пише
+ „лицето е вписано като съдружник/собственик", а не „лицето е собственик според ТР".
+
+
+ Ограниченията, честно. Търговският регистър{' '}
+ не съдържа ЕГН, затова съвпадението по трите имена не е абсолютно
+ доказателство за самоличност — възможен е съименник. Затова се
+ изисква пълно съвпадение и на трите имена в{' '}
+ един и същ запис на акта (две от три не се приема; имена с инициали
+ или с латински букви не се приемат). Седалищата се променят, затова регистрираното
+ седалище се приема само ако е вписано преди декларирания период —{' '}
+ и само когато този период е известен. Ако от декларациите не може
+ да се извлече година, проверката няма с какво да се направи и връзката остава
+ скрита. Заличените записи се пропускат навсякъде.
+
+
+ Справките се опресняват месечно, а датата на справката и номерът на
+ вписването се показват на всяка връзка — така всяко твърдение носи своя източник и
+ своята давност. Актовете се пазят само служебно, за срок до 35 дни; имена на трети
+ лица от тях не се съхраняват и не влизат в никакъв публичен запис.
+
@@ -267,7 +337,11 @@ export default function ConflictMethodology() {
Прекратен дял. Ако лице подаде по-късна декларация за
собственост, която вече не изброява дадено дружество, връзката се смята за
приключила и се оттегля от публичната част — така остарял запис
- не внушава конфликт, който е приключил.
+ не внушава конфликт, който е приключил. Това обаче е извод от мълчание:
+ най-честата причина да спреш да декларираш е изтекъл мандат, а не продажба. Затова
+ преди оттеглянето сверяваме с живия акт в Търговския регистър —
+ ако лицето и днес е вписано като съдружник или собственик, дялът не е прекратен и
+ връзката остава.
@@ -363,11 +437,13 @@ export default function ConflictMethodology() {
Разглеждаме сигнала в срок до 7 работни дни. При основателен сигнал
- връзката се премахва незабавно и се записва в списък за премахване,
- който оцелява при всяко следващо обновяване — веднъж премахната
- връзка не се възстановява при повторно зареждане на данните. Премахваме връзка при
- доказана неточност; не заличаваме коректни записи по искане на изпълнител или
- възложител — данните идват от публични източници и остават публични.
+ връзката се премахва веднага след решението и се записва в списък
+ за премахване, който оцелява при всяко следващо обновяване — веднъж
+ премахната връзка не се възстановява при повторно зареждане на данните. Премахването
+ се прилага незабавно в базата; страницата може да се вижда в кеш{' '}
+ до един час след това. Премахваме връзка при доказана неточност; не
+ заличаваме коректни записи по искане на изпълнител или възложител — данните идват от
+ публични източници и остават публични.
diff --git a/apps/web/app/routes/conflict.official.tsx b/apps/web/app/routes/conflict.official.tsx
index 8ef91879..01710bf5 100644
--- a/apps/web/app/routes/conflict.official.tsx
+++ b/apps/web/app/routes/conflict.official.tsx
@@ -8,6 +8,7 @@ import { ConflictCards } from '../components/ConflictCards';
import { publicCache } from '../lib/cache';
import { withDbRetry } from '../lib/retry';
import { seoMeta } from '../lib/meta';
+import { declaredStakeNoun } from '../lib/conflicts';
// One office-holder's declared ownership links. Reads private-ownership interest_links only. 404 (not an
// empty page) when the person has no published link — a bare page under someone's name reads as an
@@ -39,6 +40,9 @@ export async function loader({ params, context }: Route.LoaderArgs) {
export default function ConflictOfficial({ loaderData }: Route.ComponentProps) {
const { official, links } = loaderData;
+ // Family-AWARE, not family-blind: the page must not assert an own stake above cards that say „свързано
+ // лице", and must not go vague where the stake really is the official's own (§2.6).
+ const stake = declaredStakeNoun(links);
return (
<>
Данните са от собствените декларации на лицето (публичен регистър на КПКОНПИ),
- съпоставени точно с регистъра на изпълнителите. Показваме само 100% съвпадения и само
- собствен деклариран дял. Сигнал за неточност:{' '}
- Методология → Поправки.
+ съпоставени точно с регистъра на изпълнителите. Показваме само 100% съвпадения, и само
+ когато самоличността на дружеството е потвърдена от Търговския регистър. Сигнал за
+ неточност: Методология → Поправки.
= {}): ConflictLink {
contemporaneousValueEur: 30_000_000,
firstContractYear: '2020',
lastContractYear: '2024',
- sourceUrl: 'https://register.cacbg.bg/2024/i.xml',
+ sourceUrl: 'https://register.cacbg.bg/2024/x.xml',
+ // #279: a link only reaches the DTO when its identity rests on a Trade Register fact.
+ evidenceKind: 'document',
+ registryRole: 'owner',
+ registryEntryNumber: '20110502101007',
+ registryEntryDate: '2011-05-02',
+ registryLookupDate: '2026-08-05',
...over,
};
}
@@ -94,6 +100,29 @@ describe('/conflicts/official/:id — render', () => {
expect(card.textContent).not.toContain('Кмет Тестов');
});
+ it('a family-only page never asserts the official owns the stake (§2.6)', async () => {
+ // The CARD labels were made tense-neutral and family-aware, but the page lede and the section hint
+ // still read „декларирало собствен дял" — a second source of truth on the very page that renders a
+ // relative's stake. On a family-only page that is a false claim about the named official, printed
+ // above a card that correctly says „свързано лице".
+ await mount(ConflictOfficial as never, {
+ official: 'Кмет Тестов',
+ links: [link({ relation: 'related', company: 'ЕВРОСТРОЙ 21 ЕООД', eik: '333' })],
+ });
+ expect(text()).not.toContain('собствен дял');
+ expect(text()).toContain('деклариран дял на свързано лице');
+ });
+
+ it('an own-stake page still says so — the wording is family-AWARE, not family-blind', async () => {
+ // POSITIVE CONTROL. Removing the claim everywhere would satisfy the assertion above while making the
+ // page vaguer than the data warrants: a self stake IS the official's own and should read that way.
+ await mount(ConflictOfficial as never, {
+ official: 'Кмет Тестов',
+ links: [link({ relation: 'owns', company: 'ЕВРОСТРОЙ 21 ЕООД', eik: '333' })],
+ });
+ expect(text()).toContain('собствен дял');
+ });
+
it('meta() names the person in the title and marks the page noindex', () => {
const tags = officialMeta({
data: { official: 'Иван Петров', links: [] },
@@ -133,6 +162,35 @@ describe('/conflicts/company/:eik — render', () => {
});
describe('/conflicts/methodology — render', () => {
+ it('discloses the matching rule verbatim — every rung, and what each may conclude', async () => {
+ // ADR-0021 E10 makes this page the disclosure of the rule, and ADR-0033 decision 7 makes it a LAUNCH
+ // CONDITION rather than a follow-up: a heuristic that asserts something about a named person is only
+ // defensible if the reader can see exactly what was asserted and why. Nothing but a test keeps the
+ // page in step with the ladder — the rule can change in evidence.mjs and leave the page describing a
+ // system that no longer exists, which is worse than not disclosing it at all.
+ await mount(ConflictMethodology as never, {});
+ const t = text();
+ // every rung of the ladder, by the name the seal and the card use
+ for (const rung of ['Документ', 'Потвърдено', 'Оборена', 'Неизвестна'])
+ expect(t).toContain(rung);
+ // rung 1 — the joint-stock bar and its reason (the „11 акции" trap)
+ expect(t).toContain('Акционерна форма');
+ expect(t).toContain('не е публична');
+ // rung 2 — all three names, one record, and the two refusals
+ expect(t).toContain('пълно съвпадение и на трите имена');
+ expect(t).toContain('един и същ запис');
+ // ADR-0035 — the company gate, the part a reader most needs to judge the claim
+ expect(t).toContain('Съвпадението по име само по себе си не стига');
+ // R10 — the seat's temporal guard, both halves
+ expect(t).toContain('вписано преди декларирания период');
+ expect(t).toContain('когато този период е известен');
+ // the honest limit: no ЕГН, so a homonym is possible
+ expect(t).toContain('не съдържа ЕГН');
+ expect(t).toContain('съименник');
+ // what the register proves and what it does not — the distinction the whole surface rests on
+ expect(t).toContain('самоличността на дружеството');
+ });
+
it('states the three libel rails in plain language', async () => {
await mount(ConflictMethodology as never, {});
const t = text();
diff --git a/apps/web/app/routes/conflicts.render.test.tsx b/apps/web/app/routes/conflicts.render.test.tsx
index 68dad5fb..814c3670 100644
--- a/apps/web/app/routes/conflicts.render.test.tsx
+++ b/apps/web/app/routes/conflicts.render.test.tsx
@@ -36,6 +36,12 @@ function link(over: Partial = {}): ConflictLink {
firstContractYear: '2020',
lastContractYear: '2024',
sourceUrl: 'https://register.cacbg.bg/2024/i.xml',
+ // #279: a link only reaches the DTO when its identity rests on a Trade Register fact.
+ evidenceKind: 'document',
+ registryRole: 'owner',
+ registryEntryNumber: '20110502101007',
+ registryEntryDate: '2011-05-02',
+ registryLookupDate: '2026-08-05',
...over,
};
}
@@ -47,6 +53,13 @@ const familyLink = link({
company: 'ЕВРОСТРОЙ 21 ЕООД',
eik: '333',
relation: 'related', // a close relative's stake — anonymized
+ // The shape the QUERY actually yields for a family link, not the base factory's self-link default.
+ // `findPerson` searches the act for the OFFICIAL; a relative's stake is registered to the relative,
+ // so the official is never found and no `document` rung is reachable — only a seat/ЕИК confirmation.
+ // A fixture carrying `document`/`owner` here tested a row that cannot exist and hid the assertion
+ // below, which is the one that matters: no registry-role claim on an anonymized card.
+ evidenceKind: 'confirmed' as const,
+ registryRole: null,
ownInstitution: false,
contractCount: 1,
contractValueEur: 250_000,
@@ -188,8 +201,16 @@ describe('/conflicts route — render', () => {
(a) => a.textContent === 'декларация',
);
expect(sourceAnchor?.getAttribute('href')).toBe('https://register.cacbg.bg/2024/i.xml');
- // the zero-contract link has no toggle (contractCount === 0) and a muted „—" source
- expect(text()).toContain('—');
+ // Scoped to the card that actually has sourceUrl: null. A page-wide toContain('—') passes on any
+ // em-dash anywhere — including the ones the value and date cells render — so it would survive the
+ // source branch being deleted outright.
+ const cards = container.querySelectorAll('.conflict-card');
+ const noSourceCard = [...cards].find((c) => c.textContent?.includes('ПРАЗЕН ООД'))!;
+ expect(noSourceCard).toBeTruthy();
+ expect(
+ [...noSourceCard.querySelectorAll('a')].some((a) => a.textContent === 'декларация'),
+ ).toBe(false);
+ expect(noSourceCard.textContent).toContain('—');
});
it('a zero-contract link renders no „Виж договорите" toggle', async () => {
@@ -238,3 +259,57 @@ describe('/conflicts route — render', () => {
).not.toBeNull();
});
});
+
+describe('Trade Register evidence on the card (#279, ADR-0033)', () => {
+ it('renders the registry fact the link rests on, so the card explains itself', async () => {
+ await renderConflicts([link({ evidenceKind: 'document', registryRole: 'owner' })]);
+ const text = container.textContent ?? '';
+ expect(text).toContain('Регистър');
+ expect(text).toContain('лицето е вписано като съдружник/собственик');
+ expect(text).toContain('вписване 2011-05-02'); // WHICH entry
+ expect(text).toContain('справка 2026-08-05'); // and HOW FRESH it is
+ // The entry NUMBER is what makes the claim findable in the register — a date alone does not
+ // identify a record. It was carried to every client in the DTO and never rendered, which is the
+ // one payload that costs bytes and answers nothing (cefothe, #309).
+ expect(text).toContain('20110502101007');
+ });
+
+ it('omits the entry number rather than printing an empty label when there is none', async () => {
+ // POSITIVE CONTROL for the row's shape: a confirmed link (seat/ЕИК) has no act entry to cite, so
+ // the label must be absent entirely — not „· №" with nothing after it, which reads as missing data
+ // rather than as an inapplicable field. Scoped to the evidence label: „№" alone is the card RANK.
+ await renderConflicts([
+ link({ evidenceKind: 'confirmed', registryRole: null, registryEntryNumber: null }),
+ ]);
+ const text = container.textContent ?? '';
+ expect(text).toContain('Регистър');
+ expect(text).not.toContain('· №');
+ });
+
+ it('a seat/ЕИК confirmation never implies somebody was found in the act', async () => {
+ await renderConflicts([link({ evidenceKind: 'confirmed', registryRole: null })]);
+ const text = container.textContent ?? '';
+ expect(text).toContain('самоличност, потвърдена по декларирани данни');
+ expect(text).not.toContain('вписано като');
+ });
+
+ it('a FAMILY card never carries a registry-role claim — the relative is not in the act we read', async () => {
+ // The production shape for a family link, which the fixture used to contradict: `findPerson` looks
+ // for the OFFICIAL, and a relative's stake is registered to the relative, so the official is never
+ // found and the rung can only ever be `confirmed`/`registryRole: null`. A card that said „лицето е
+ // вписано като съдружник/собственик" here would assert that the named official is recorded in the
+ // register as an owner of this company — a false, named, libel-shaped claim, on the one card whose
+ // whole design is that the stakeholder stays anonymous (ADR-0030/0032).
+ await renderConflicts([familyLink]);
+ const familyCard = container.querySelector('.conflict-card')!;
+ expect(familyCard.textContent).toContain('деклариран дял на свързано лице');
+ expect(familyCard.textContent).toContain('самоличност, потвърдена по декларирани данни');
+ expect(familyCard.textContent).not.toContain('вписано като');
+ });
+
+ it('links out to the register so a reader can check the same act we read', async () => {
+ await renderConflicts([link({ eik: '201122335' })]);
+ const hrefs = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href') ?? '');
+ expect(hrefs.some((h) => h.includes('201122335'))).toBe(true);
+ });
+});
diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx
index 8eb1175b..e3347461 100644
--- a/apps/web/app/routes/contract.tsx
+++ b/apps/web/app/routes/contract.tsx
@@ -222,7 +222,13 @@ export default function Contract({ loaderData }: Route.ComponentProps) {
- Показана е публикуваната стойност от източника, без СИГМА да я коригира. Виж{' '}
- методология.
+ {v.currentValueDoubled
+ ? 'Текущата стойност изглежда двойно отчетена в източника и затова не се показва. '
+ : 'Показана е публикуваната стойност от източника, без СИГМА да я коригира. '}
+ Виж методология.
+ {/* #305 residual: an uncorrectable double-count — the source's value_after is the
+ untrusted doubled figure, so show „—" and mark the row rather than a number we
+ can't stand behind. A `restated` row is the opposite: СИГМА corrected the doubled
+ total from the основание text, so we show the corrected number and flag that we
+ rewrote it. */}
diff --git a/coverage-baseline.json b/coverage-baseline.json
index 5abe85c9..064f448f 100644
--- a/coverage-baseline.json
+++ b/coverage-baseline.json
@@ -6,24 +6,24 @@
"branches": 58.2
},
"apps/web": {
- "lines": 89.7,
- "branches": 81.8
+ "lines": 91,
+ "branches": 82.4
},
"packages/config": {
"lines": 92.8,
"branches": 72.2
},
"packages/db": {
- "lines": 94.2,
- "branches": 79
+ "lines": 94.5,
+ "branches": 79.3
},
"packages/ingest": {
- "lines": 85.8,
- "branches": 80
+ "lines": 86.3,
+ "branches": 80.4
},
"packages/shared": {
- "lines": 95.4,
- "branches": 80
+ "lines": 95.5,
+ "branches": 80.8
}
}
}
diff --git a/docs/README.md b/docs/README.md
index 0a3bf6e0..16f03f97 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,6 +10,12 @@
- [`etl-pipeline-state.md`](etl-pipeline-state.md) — анализ на текущото състояние на ETL pipeline-а.
- [`etl-architecture.md`](etl-architecture.md) — целевата ETL архитектура (RFC): предложение за състоянието и реда на изпълнение.
- [`v1-implementation-plan.md`](v1-implementation-plan.md) — precompute слоят и пагинацията (защо rollup-и и keyset вместо per-request GROUP BY / OFFSET).
+- [`implementation-plans/286-ocds-amendment-unp.md`](implementation-plans/286-ocds-amendment-unp.md) — защо OCDS анексите не се свързват с договор (OCID вместо УНП) и планът за поправка през bridge-а `tender.id → УНП` + prefer-EOP dedup (#286).
+<<<<<<< HEAD
+- [`implementation-plans/306-amendment-contract-namespace-link.md`](implementation-plans/306-amendment-contract-namespace-link.md) — защо 1 937 EOP анекса не се свързват с договор (номерът на анекса е в друго именно пространство от деловодния номер) и планът за поправка чрез value-anchor (`value_before → signing_value`, 99.99% точност) (#306).
+=======
+- [`implementation-plans/305-amendment-value-double-count.md`](implementation-plans/305-amendment-value-double-count.md) — защо стойността на анекс се удвоява (ЦАИС ЕОП слага новия **тотал** в полето за промяна) и планът за откриване/поправка: `annex_total_suspect` флаг + текстова хеуристика за възстановяване на истинския тотал (#305).
+>>>>>>> origin/main
- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate-ът: hard asserts върху тоталите при import/CI.
- [`anomaly-report.md`](anomaly-report.md) — cross-row аномалии при опресняване: какво `value_flag` не хваща на ниво отделен договор.
- [`deploy.md`](deploy.md) — деплой към Cloudflare: двата Worker-а (`sigma`, `sigma-etl`) и споделеният D1 per environment.
diff --git a/docs/adr/0007-scope-and-certainty-bar.md b/docs/adr/0007-scope-and-certainty-bar.md
index 18fcb446..c41f4255 100644
--- a/docs/adr/0007-scope-and-certainty-bar.md
+++ b/docs/adr/0007-scope-and-certainty-bar.md
@@ -1,6 +1,7 @@
# ADR-0007: Scope and certainty bar
- Status: Accepted
+- Amended by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) — решения 2 и 3 (изричното изключение за „евристика, която твърди")
- Date: 2026-07-05
- Deciders: lb (Head of AI, ИО), Claude
- Related: spec §1–2, §8
diff --git a/docs/adr/0009-name-uniqueness-guard-and-publish-tiers.md b/docs/adr/0009-name-uniqueness-guard-and-publish-tiers.md
index 7803e901..8783b3ac 100644
--- a/docs/adr/0009-name-uniqueness-guard-and-publish-tiers.md
+++ b/docs/adr/0009-name-uniqueness-guard-and-publish-tiers.md
@@ -1,8 +1,9 @@
# ADR-0009: Name-uniqueness is not absolute → single-ЕИК guard + publish tiers
-- Status: Accepted
+- Status: Superseded by [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md)
- Date: 2026-07-05
- Deciders: lb, Claude
+- Superseded by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md)
- Related: spec §5; refines [ADR-0008](0008-deterministic-name-to-eik-resolution.md)
## Context
diff --git a/docs/adr/0010-pii-posture.md b/docs/adr/0010-pii-posture.md
index 8f09ead8..cf9898a0 100644
--- a/docs/adr/0010-pii-posture.md
+++ b/docs/adr/0010-pii-posture.md
@@ -1,6 +1,7 @@
# ADR-0010: PII posture
- Status: Accepted
+- Amended by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) — решения 3 и 6 (кешът от актове и срокът на съхранение)
- Date: 2026-07-05
- Deciders: lb, Claude
- Related: spec §8; КЗЛД guidance; GDPR Art. 6(1)(c)/(f), Art. 85
diff --git a/docs/adr/0015-tr-name-uniqueness-census.md b/docs/adr/0015-tr-name-uniqueness-census.md
index b3fb2789..c86d8eec 100644
--- a/docs/adr/0015-tr-name-uniqueness-census.md
+++ b/docs/adr/0015-tr-name-uniqueness-census.md
@@ -1,8 +1,9 @@
# ADR-0015: TR name-uniqueness census (promoting tier-C generic-name matches)
-- Status: Accepted (design; implemented as a Phase-1 pipeline step)
+- Status: Superseded by [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md)
- Date: 2026-07-05
- Deciders: lb, Claude
+- Superseded by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md)
- Related: [ADR-0009](0009-name-uniqueness-guard-and-publish-tiers.md); spec §5
## Context
diff --git a/docs/adr/0017-name-collision-tier-gate.md b/docs/adr/0017-name-collision-tier-gate.md
index 8d4a234b..38ed31b5 100644
--- a/docs/adr/0017-name-collision-tier-gate.md
+++ b/docs/adr/0017-name-collision-tier-gate.md
@@ -1,8 +1,9 @@
# ADR-0017: A globally non-unique name cannot ride the name-distinctive tier — even with a certain ЕИК
-- Status: Accepted
+- Status: Superseded by [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md)
- Date: 2026-07-05
- Deciders: lb, Claude
+- Superseded by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) — the name-distinctive tier it gates leaves the publishing path; the `nameGloballyUnique` precondition survives, narrowed to the weakest evidence rung
- Related: [ADR-0009](0009-name-uniqueness-guard-and-publish-tiers.md), [ADR-0016](0016-free-text-entity-resolution.md); `scripts/cacbg/load.mjs`, `scripts/cacbg/audit.mjs`
## Context
diff --git a/docs/adr/0021-methodology-page-and-temporal-freshness.md b/docs/adr/0021-methodology-page-and-temporal-freshness.md
index 810a74b2..d2f0d9cd 100644
--- a/docs/adr/0021-methodology-page-and-temporal-freshness.md
+++ b/docs/adr/0021-methodology-page-and-temporal-freshness.md
@@ -1,6 +1,7 @@
# ADR-0021: Public methodology/corrections page (E10) + temporal freshness & divestment expiry (E11)
- Status: Accepted
+- Amended by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) — E11 (етикетите за настояще време се отлагат за фаза 2)
- Date: 2026-07-06
- Deciders: lb, Claude
- Related: [ADR-0007](0007-scope-and-certainty-bar.md), [ADR-0019](0019-private-interest-vs-ex-officio-classification.md), [ADR-0020](0020-conflict-explorer-surface-posture.md); spec §5/§8/§9; `scripts/cacbg/load.mjs`, `apps/web/app/routes/conflict.methodology.tsx`, `packages/db/src/queries/related-persons.ts`
diff --git a/docs/adr/0023-anonymized-family-ownership-surface.md b/docs/adr/0023-anonymized-family-ownership-surface.md
index 3ead3824..d5dcea50 100644
--- a/docs/adr/0023-anonymized-family-ownership-surface.md
+++ b/docs/adr/0023-anonymized-family-ownership-surface.md
@@ -1,8 +1,9 @@
# ADR-0023: Anonymized close-relative (family) ownership surface
-- Status: Accepted
+- Status: Superseded by [ADR-0030](0030-family-ownership-withheld-nameless-aggregate.md)
- Date: 2026-07-07
- Deciders: lb, Claude
+- Superseded by: [ADR-0030](0030-family-ownership-withheld-nameless-aggregate.md)
- Related: [ADR-0007](0007-scope-and-certainty-bar.md), [ADR-0010](0010-pii-posture.md), [ADR-0019](0019-private-interest-vs-ex-officio-classification.md), [ADR-0022](0022-public-surface-private-ownership-only.md); `scripts/cacbg/parse.mjs`, `scripts/cacbg/load.mjs`, `packages/db/src/queries/related-persons.ts`, `apps/web/app/routes/conflict*.tsx`
## Context
diff --git a/docs/adr/0028-declared-eik-is-a-determining-identifier.md b/docs/adr/0028-declared-eik-is-a-determining-identifier.md
index 9f123482..9ad76597 100644
--- a/docs/adr/0028-declared-eik-is-a-determining-identifier.md
+++ b/docs/adr/0028-declared-eik-is-a-determining-identifier.md
@@ -1,6 +1,7 @@
# ADR-0028: Declared ЕИК is a determining identifier — tier `A_eik`, exempt from the TR census
- Status: Accepted
+- Amended by: [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) — декларираният ЕИК остава определящ, но вече иска и регистърно доказателство
- Date: 2026-07-11
- Deciders: lb, Claude
- Related: [ADR-0009](0009-name-uniqueness-guard-and-publish-tiers.md), [ADR-0015](0015-tr-name-uniqueness-census.md), [ADR-0016](0016-free-text-entity-resolution.md); `scripts/cacbg/load.mjs`, `scripts/cacbg/classify.mjs`
diff --git a/docs/adr/0033-registry-evidence-replaces-name-distinctiveness.md b/docs/adr/0033-registry-evidence-replaces-name-distinctiveness.md
new file mode 100644
index 00000000..82633fb7
--- /dev/null
+++ b/docs/adr/0033-registry-evidence-replaces-name-distinctiveness.md
@@ -0,0 +1,364 @@
+# ADR-0033: A link publishes only against a Trade Register fact — the evidence ladder replaces the name-distinctiveness tier
+
+- Status: Accepted (design; implemented as the #279 pipeline change)
+- Date: 2026-08-05
+- Deciders: Todor (maintainer), Claude
+- Supersedes: [ADR-0009](0009-name-uniqueness-guard-and-publish-tiers.md), [ADR-0015](0015-tr-name-uniqueness-census.md), [ADR-0017](0017-name-collision-tier-gate.md)
+- Amends: [ADR-0007](0007-scope-and-certainty-bar.md) (decisions 2 and 3), [ADR-0010](0010-pii-posture.md) (decisions 3 and 6), [ADR-0021](0021-methodology-page-and-temporal-freshness.md) (E11 only — E10 stands and is strengthened), [ADR-0028](0028-declared-eik-is-a-determining-identifier.md)
+- Related: [ADR-0008](0008-deterministic-name-to-eik-resolution.md), [ADR-0011](0011-host-scoped-tls-pinning.md), [ADR-0026](0026-person-grain-name-institution.md), [ADR-0027](0027-overmerge-gate-is-telemetry-not-a-gate.md), [ADR-0031](0031-suppressions-version-controlled-fingerprinted.md), [ADR-0032](0032-family-ownership-published-under-public-interest.md); spec §3.3/§5/§8/§9, [related-persons-lia.md](../spec/related-persons-lia.md); `scripts/tr/`, `scripts/cacbg/{load,classify}.mjs`, `packages/db/src/queries/related-persons.ts`, `apps/web/app/routes/conflict*.tsx`
+
+## Context
+
+Today the identity of the company behind a declared name is decided by a heuristic. `B_distinctive`
+(ADR-0009) publishes a link when the name is *structurally* distinctive — a digit, a Latin token, or ≥3
+content words. It fails in both directions: it publishes on a bare name coincidence, and it withholds
+everything else. Of the 101 links published today, **36 rest on name coincidence alone**.
+
+The Trade Register answers the identity question directly, over a public endpoint with no authentication:
+`GET https://portal.registryagency.bg/CR/api/Deeds/{ЕИК}`. Either the declarant is named in the company's
+live deed, or facts they declared match the ones registered against that ЕИК.
+
+Four things must be on the table before the decision, because each of them changes what is being decided.
+
+**1. This is an expansion, not a tightening.** The measured surface grows from 101 links to **329 links /
+311 people / 239 companies**. „From held / published / withdrawn = 163 / 65 / 101" means **264 links we
+deliberately withhold today become named public claims**. At 329 links a 1% error rate is three falsely
+named people. The precision bar has to go **up**, not stay flat — which is why the acceptance gate below is
+a hand-labelled sample and not the control totals.
+
+**2. The second rung is a heuristic used to assert — stated precisely.** What the registry evidence
+establishes is **the identity of the company**, not that the official owns it: the ownership claim comes
+from the official's own filed declaration and is not a heuristic at all. Rung 2 („Документ") confirms that
+*the company this declared name refers to is the same legal entity as the winner we matched*, by finding
+the declarant's full name inside that company's live fields. A homonym failure therefore does not invent an
+ownership claim — it **attaches the official to the wrong company's ЕИК, contracts and money**. That is a
+different error from the one the adversarial reading suggests, and it is still a false public claim about a
+named person.
+
+A three-name subset match against free text, in a register that carries no ЕГН, is **not deterministic**.
+ADR-0007's decision list rules it out twice: item 2 (published only when the official↔company↔ЕИК
+resolution is *deterministic*) and item 3 (heuristics may *withhold* or *triage*, **never assert**). #279
+replaces a weaker heuristic with a stronger one and promotes it to grounds for assertion. That is the
+decision this ADR exists to make explicit; it cannot be smuggled in as an implementation detail.
+
+**3. The shape of the access is the permitted one.** Spec §3.3 permits „bounded per-ЕИК … thousands, not
+~900k" and forbids bulk scraping; ADR-0007 decision 1 already anticipated the „per-ЕИК lookup only, later
+leg". The crawl is one lookup per candidate ЕИК — on the order of hundreds — with a closed candidate set:
+the crawler never follows a link out of a deed. This is that later leg, not the bulk reuse.
+
+**4. The endpoint rate-limits, and the limiter is a stated preference.** An earlier spike against this same
+API triggered **HTTP 429 at roughly 50 cumulative requests ending in a burst**, and the block was then
+*sustained* — every subsequent request returned 429, including simple `/Deeds/{eik}` calls that had worked
+seconds earlier. There is no `Retry-After` and no `X-RateLimit-*` header, so a client can only avoid
+tripping the limit, never pace against a published one; 25 spaced requests were fine, so pacing matters more
+than total volume. #279 §3's „~400 companies, about 20 minutes" is therefore feasible only at its stated
+1 request / 3 seconds with no burst, and only if the crawl is resumable. The limiter is the operator's only
+available way to express a rate preference, and tuning around it empirically is precisely the posture spec
+§3.3's „NEVER bulk-scrape" exists to forbid. This is the evidence behind decision 7's crawl hygiene.
+
+**5. Parts of the source description in #279 were wrong, and were corrected by live probing** (9 sequential
+requests, 2026-08-04 — well inside the pacing above, so no 429 was encountered):
+
+- `?entryDate=` **is** honoured — a bogus-parameter control returned byte-identical output to the
+ plain request, so the difference is the parameter, not noise. `…/Fields/{ident}/History` **does** return
+ JSON, not application HTML. #279 §3 states the opposite for both.
+- A wrong `subUIC` yields **HTTP 500 `GL_ERROR_L`**, not 404 — so a 500 can never be read as „no history"
+ or „outside the register".
+- `legalForm` 4 = ООД and 10 = ЕООД (as #279 says); the nomenclature endpoint does **not** settle the enum
+ — `legalForm=4` and `legalForm=10` return a byte-identical catalogue.
+- The envelope's `fullName` carries the legal form (`"ПИМК" ООД`) while `CR_F_2_L` is bare. The joint-stock
+ bar can therefore be derived from the ЗТРРЮЛНЦ-mandated suffix, at zero extra requests.
+- Erasure is **structural, not textual**. Entities inside one field are separated by
+ `
` blocks delimited by ``.
+ An erased entity is such a block carrying an erasure marker. **Correction, on implementation:** this ADR
+ first named that marker `field-text--erased`. Re-measured against the full live deed for ЕИК 115536179,
+ that class occurs **zero** times; the register emits `
` (with
+ ``) and the erased container carries **no `field-text` paragraph at
+ all**. The parser therefore treats *either* spelling as erasure — honouring both costs nothing, while
+ assuming one costs a wrong publish. `fieldOperation` is **not** the signal: it reads 2 on both erased
+ fields in that deed but 1 on the erased history records W0 sampled, so it is an undocumented enum we do
+ not rely on.
+- Erased versions are **live in the current deed**: the first company sampled carries a fully-erased
+ `CR_F_23_L` dated 2013-07-16, which read naïvely becomes „latest ownership entry: 2013-07-16" and feeds
+ the refutation rule.
+- Seats move. The same company's `CR_F_5_L` shows entry dates 2010-11-04 and 2014-01-23.
+- **An ЕИК that is not a търговец answers `HTTP 200` with a ZERO-BYTE body** — not the 404, and not the
+ HTML, that §3 predicts. Measured on Община София (`000696327`): empty on two consecutive requests,
+ while a real company returned its full 34,398-byte deed in the same window, so it is the register's
+ answer rather than an outage. This is what the „извън ТР" rung actually looks like on the wire. The
+ distinction that keeps R6 honest is therefore the **status**, not the empty body: empty under 200 is
+ a documented negative and may be cached permanently; empty under 5xx is a failure and stays transient.
+
+The as-of capability that `?entryDate=` unlocks would fix the two weakest rungs, but it **re-baselines every
+control number in #279 §10**. It is therefore deliberately out of scope here and becomes a separate change
+under a new rules version — which decision 6 makes the only sanctioned way the surface may move.
+
+## Decision
+
+### 1. The evidence ladder replaces the publish tiers
+
+For every resolved link (person × ЕИК) with a fetched deed, **the first matching rung wins**:
+
+| # | Outcome (data label) | Condition | Effect |
+|---|---|---|---|
+| 1 | **„Бар: акционерна форма"** — joint-stock bar | the company is a joint-stock form (АД / ЕАД / КДА) | never published, whatever follows |
+| 2 | **„Документ"** — documented | the declarant's full name appears in a live `CR_F_7_L` / `CR_F_18_L` / `CR_F_19_L` / `CR_F_23_L` | published; the role (owner vs manager only) is kept for the label |
+| 3 | **„Потвърдено"** — confirmed | the normalized declared seat equals the registered seat for that ЕИК, **or** the declarant wrote the ЕИК in the declaration | published |
+| 4 | **„Оборена"** — refuted | *own* stake only: the person appears in no live field, **and** the latest entry date across the live ownership fields is strictly before the first declared year | link withdrawn |
+| 5 | **„Неизвестна"** — unknown | anything else | held |
+| 6 | **„Извън ТР"** — outside the register | the ЕИК is not in the register (ДЗЗД, БУЛСТАТ associations) | held |
+
+The Bulgarian labels are the persisted vocabulary — they reach the data, the audit and the methodology
+page — so they are fixed here rather than translated at each layer.
+
+Erased versions are skipped everywhere the live state is read — otherwise the date on an erased record can
+„certify" a state that is not in force.
+
+`publish_tier` carries the evidence kind. **`B_distinctive` leaves the publishing path entirely**;
+name distinctiveness survives only as an ordering signal for the review queue. Rung 3's seat leg takes the
+declared seat **only from declarations by the same person for the same company**: 4.9% of company-name keys
+carry more than one distinct declared seat, so a company-only key would let one person's seat confirm
+another person's link.
+
+This supersedes ADR-0009 (the tier ladder) and ADR-0015 (the name census, whose only job was to unblock
+`C_hold`; `tr-census.mjs` and its `promote()` are deleted). ADR-0028's holding survives — a declared ЕИК
+*is* the identity — but it is now rung 3 rather than a tier of its own, it is subject to rung 1, and its
+census-exemption clause is moot.
+
+### 2. A heuristic may ground an assertion here — the argued exception to ADR-0007
+
+Rung 2 asserts on a name match. We accept it, on these grounds and no wider:
+
+- **It is a full-subset match, patronymic included.** Partial (2-of-3) matching is refused outright: of 301
+ matches measured, 46 were two-token only — precisely the homonym risk. A declarant name with fewer than
+ three tokens can never earn „Документ".
+- **It is scoped to a single registry entity.** Tokens are matched only within one `record-container`
+ block, after erased blocks are dropped and after HTML entities are decoded. Matching against a whole
+ field's text would combine the given name of one person with the surname of another — the defect that
+ ships a libel.
+- **The company identity behind it passed preventive control.** The candidate ЕИК comes from the
+ deterministic exact-name resolution of ADR-0008/#226, not from fuzzy matching.
+
+ > **Superseded on this point by [ADR-0035](0035-registry-evidence-must-also-establish-the-company.md).**
+ > Deterministic is not the same as correct: that resolution ranges over PROCUREMENT WINNERS only, so an
+ > official whose real company never bid resolves to a same-named winner, and a homonym in the winner's
+ > deed completes a link false in both halves. Rung 2 now requires a corroborator for the COMPANY —
+ > declared ЕИК, a matching declared seat, or a distinctive фирма — and the uncorroborated remainder is
+ > withheld as `document_uncorroborated` and counted.
+- **It is corroborated, and that is the whole difference.** A bare TR name is spec §4's *weakest* join —
+ no ЕГН, no birthdate, not even ADR-0026's `(name, ведомство)` grain. The registry-graph spike that
+ explored this API concluded, for its own use, that no person-derived edge may reach a user-visible
+ surface at all, and it named the one cheap corroborator that would change that: *an officer who also
+ appears as a declarant with a declared stake in the same ЕИК, confirmed by an independent source*. That
+ is exactly what rung 2 is. The register supplies the name-in-this-company fact; the official's own filing
+ supplies the stake. Neither alone would publish. **The corroboration is what licenses the assertion, so
+ the rule must never be extended to a name-only join** — matching a person across two companies, or
+ treating a registry name as an identity in its own right, stays forbidden.
+- **The filters that can only withhold are retained, not removed.** `nameGloballyUnique` and
+ `nameDistinctiveness` stay in the pipeline as an **AND-gate on the weakest rung only** („Потвърдено").
+ They cost near-zero recall and preserve ADR-0017's outcome even though its subject — the name-distinctive
+ tier — is gone.
+- **Homoglyphs are not folded.** `company-name-key.ts` deliberately does not fold Cyrillic↔Latin; person
+ names take the same posture. A Latin letter inside a name means no „Документ", and the occurrence is
+ counted rather than silently dropped.
+
+**The residual collision rate is estimated, not measured, and we say so.** Bulgarian names are three-part
+by statute (ЗГР чл. 9) and the API renders full triples consistently. A triple of common components —
+Георги · Иванов · Петров — is on the order of 10⁻⁵ of ~3.2M men, so roughly 29 people nationally share it;
+but the population that can produce a false link is company officers, a ~1% slice, which squares the
+probability to ~10⁻⁴ per colliding pair. That is an argument for plausibility, not a measurement, and the
+residual concentrates on exactly the common names. It is therefore **not** the basis on which this
+publishes — the hand-labelled sample of decision 7 is. Hyphenated surnames count as one token; Latin-script
+names are counted the same way but flagged, since a three-token foreign name is not a patronymic triple.
+
+The tie-breaker for every rung is the repo's own sentence, from
+`packages/shared/src/company-name-key.ts`: **„When in doubt the key stays MORE specific (a recall miss is
+safe; an over-merge is not)."**
+
+This **amends ADR-0007 decisions 2 and 3**: a published claim may now rest on a disclosed, bounded,
+entity-scoped full-name match against a public register, in addition to deterministic facts. Everything
+else in ADR-0007 stands — in particular that every heuristic is disclosed on the methodology page and
+labelled in the data, which decision 7 makes a launch condition rather than a follow-up.
+
+### 3. The joint-stock bar is a union of three independent signals
+
+The bar exists because the shareholder book is not public (so the claim is unverifiable) and a parcel of
+listed shares is not a material ownership conflict (the „11 Trace shares → €88M" trap). It fires if **any**
+of the following says joint-stock:
+
+1. `closelyHeldForm` on the **declared** name (`load.mjs`) — a different, earlier stage; it **stays**, and
+ is not redundant with the two below;
+2. the фирма suffix taken from the deed envelope's `fullName`, through that same tested predicate;
+3. the numeric `legalForm` code from the deed.
+
+**An unknown `legalForm` code withholds and is reported** — it never falls through to publication. The enum
+is known only from four observed values and the nomenclature endpoint does not settle it (Context 5), so
+fail-open here would be a bar that silently stops barring. `КДА` — which rung 1 explicitly requires barring
+— is absent from both `JOINT_STOCK` and `FORM_TOKENS` in `classify.mjs` today and is added.
+
+**The named open question is ЕАД.** #279 §3 lists 5 = АД, and the spike's catalogue-derived table also
+reads 5 as АД/ЕАД jointly — but ООД and ЕООД turned out **not** to share a code (4 and 10), so a separate
+ЕАД code is the likelier reading and no observation settles it. A single-owner joint-stock company is
+precisely the shape a declarant is most likely to hold and rung 1 most needs to bar, so this is the one
+place where a fail-open enum would do real damage. Signal 2 (the ЗТРРЮЛНЦ suffix on `fullName`) covers it
+independently of the code, which is why the bar is a union and not a lookup.
+
+### 4. Termination is reconciled against the live deed — reversing ADR-0021 E11 for own stakes
+
+ADR-0021 E11 marks an ownership link `withdrawn` when the company is absent from the person's latest
+ownership filing. That is an inference from silence, and its commonest cause is a finished mandate rather
+than a sale. Before the withdrawal takes effect, every terminated **own** stake is reconciled against the
+live deed: a person still named in a live ownership field (`CR_F_18/19/23_L`) has not divested, and the
+link surfaces. Family stakes are never reconciled — the relative's name is neither stored nor checked
+(ADR-0010 decision 4, ADR-0032 decision 2), so declared termination applies to them directly.
+
+**Phase 1 uses the reconciliation only to avoid withdrawing the link.** The „и към днешна дата" labels of
+#279 §7, and the post-period contracts they license, are **deferred to phase 2 behind an LIA addendum**:
+those labels assert a present tense about a named person, on evidence whose freshness is bounded by the
+cache refresh cycle, and they change the claim's shape enough to need the balancing assessment updated
+first. The derived live status is therefore recomputed on every run and **never sealed**.
+
+ADR-0021 **E10 is untouched and strengthened** — see decision 7.
+
+### 5. The deed cache — amends ADR-0010
+
+A deed contains third-party personal data: addresses of natural persons, and the names of people who are
+not office holders.
+
+- **Extraction stays inside ADR-0010 decision 3.** `CR_F_5_L` yields only the „Населено място:" segment;
+ no parser function returns an address. Decision 3 is re-affirmed, not overridden.
+- **Storage is what changes, and that is ADR-0010 decision 6.** Raw deeds are cached under git-ignored
+ `scratch/tr/`, behind the same refuse-to-run guard as the declaration cache, with a **35-day retention**
+ (one refresh cycle plus slack) and a purge step in the same job. Decision 6's scope extends from „raw
+ declaration XML … deleted post-spike" to a second source with a stated TTL.
+- **The cache index holds no name at all** — only ЕИК, dates, codes and verdicts, plus a body hash instead
+ of any content excerpt. Names exist solely in the raw JSON files, are read only to produce a boolean, and
+ never enter a public table, a response, or a log. ЕГН was absent from every payload examined, and the
+ index additionally refuses any ten-digit run in a text column — sound because an ЕИК is 9 or 13 digits,
+ never 10.
+- **The deed's beneficial-ownership fields are deliberately not used.** A deed also carries `CR_F_550_L`
+ (действителни собственици, чл. 63 ЗМИП) and the control fields `CR_F_537/538_L`. Using them would make a
+ beneficial-ownership claim, which ADR-0007 decision 1 parked after CJEU C-37/20. Rung 2 reads the four
+ fields #279 names and no others; widening it is a new decision, not an improvement.
+- **The sealed `matched_fact` is a closed vocabulary** (`seat:`, `role:owner:`, `eik`),
+ enforced by a pattern check in the audit. Without that, the matched *name* eventually gets stored there
+ as a convenience, which is exactly what #279 §9 forbids.
+
+### 6. Monotonicity is a gate, not a store — and #279 §8 is corrected
+
+#279 §8 requires the evidence seal to be kept „forever" and recomputation to be strictly additive. As
+written that is false, and the implementation must not pretend otherwise:
+
+- §7's labels flip. A person who leaves the register must lose the label *and* the post-period contracts it
+ licensed. (Phase 1 avoids this by not shipping the labels at all — decision 4.)
+- A permanent seal and a live, expiring cache contradict each other.
+- A **court-annulled entry** (чл. 29 ЗТРРЮЛНЦ) invalidates the evidence without any rules change. It is
+ wired to the correction path of ADR-0031 and named as a ground in the suppression runbook.
+
+Therefore: seals are **re-derived deterministically** on every run — a seal is written for *every* link,
+including held ones, so the review queue explains itself — and monotonicity is enforced as a **gate**. The
+audit compares against the pre-wipe export and raises a hard finding when a previously published link
+disappears under an **unchanged** `rules_version`; under a changed version it degrades to a printed diff.
+Removal remains an intentional event, and each ground has an expressible mechanism — a gate that hard-fails
+the only removals it sanctions is a deadlock, not a rail:
+
+| Ground | Mechanism | Why not one of the others |
+|---|---|---|
+| The rules changed | a `rules_version` bump | — |
+| The evidence is void (court annulment, wrong person) | ADR-0031 suppression — `status` flips to `suppressed`, so the audit reads the current status and treats it as declared | the link is correctly built; only its publication is wrong |
+| The input was wrong | `scripts/cacbg/link-corrections.jsonl`, fingerprinted like the suppression list; `load.mjs` flags the key in the pre-wipe snapshot | correcting the input *unbuilds* the link, so a suppression on it matches nothing and trips the unused-entry rail |
+
+Both non-rules grounds are one-shot, version-controlled, and reviewed in git; neither is silent — the audit
+prints every declared removal with the ground that licensed it. An acknowledgement that matches nothing
+fails the build, because a stale one would pre-clear a *future* disappearance of that same link.
+
+### 7. Launch gates
+
+None of these are follow-ups.
+
+- **Precision is proven by a hand-labelled sample, not by the control totals.** A human verifies against the
+ portal: every reconciled link, every refuted link, and random samples of „Документ" and „Потвърдено".
+ **Pass mark: zero wrong-company and zero wrong-person errors.** The control numbers of #279 §10 are a
+ reproducibility check, and two things had to be settled before they could serve as one.
+
+ **The §5/§10 discrepancy is a dropped histogram category, not a disputed measurement.** §5's rungs carry
+ their own control counts: 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 cause is visible in the labels. The identity row names its second bucket **„седалище"** — one leg of
+ rung 3 — while the evidence row two lines below names the same rung **„потвърдено"** (251 / 78). Rung 3
+ has two legs; the row tallied one and presented the result as a partition of the resolved set. And the
+ stated total 568 equals that row's sum exactly, which makes it a figure derived from the histogram rather
+ than measured independently. So both move together: the bucket becomes **„потвърдено: 105"** and the
+ resolved total becomes **571**. F8 measures against those and reports if its own count disagrees — the
+ reconciliation is not permitted to adopt whichever reading makes it pass.
+
+ **Not a discrepancy, and not to be „fixed":** „извън ТР" is 4 in §10 and 3 in §3/§11 because the units
+ differ — 3 ЕИК that are not in the register, carrying 4 links between them. §10's row counts links.
+
+ The numbers must also be re-measured on top of the §12 phantom-row fix, which changed the corpus — that
+ fix has landed as #281 (87 phantom rows across 15 sets; 256,286 announced − 87 = 256,199, covered exactly
+ by 255,582 fetched + 617 missing at source).
+- **Every anti-false-zero control is a positive control.** „Sofia seat confirmations: 0" is indistinguishable
+ from a broken normalizer without one; so is a joint-stock bar with no marginal effect over
+ `closelyHeldForm`, and a matcher that always returns false (ADR-0027's lesson).
+- **A partial cache must fail closed.** A missing *or* incomplete registry cache makes the loader throw. An
+ 80%-restored cache would otherwise yield roughly 80 published links — above the ship floor of 50 — and so
+ would ship a decimated surface and wipe the rest from production. The ship floor rises accordingly and is
+ passed explicitly rather than defaulted.
+- **Crawl hygiene.** „Outside the register" is permanent only from a documented positive response; 429,
+ 5xx and timeouts are transient and are never cached as a negative. A 429 stops the run rather than
+ marking anything, and is never retried — „5 retries with growing backoff" and „429 stops the run" are
+ consistent only if retries exclude 429. Given Context 4, the crawl is sequential, paced, resumable, and
+ ends the run on the first 429 rather than backing off into it. **The deed's returned UIC must equal the
+ requested ЕИК**, or the deed is refused: an ЕИК is TEXT everywhere because public bodies' identifiers are
+ exactly the `000…` shape that loses its leading zeros to a numeric round-trip, and the failure mode of
+ losing them is fetching, and then publishing against, a different company's deed.
+- **The methodology page carries the rule verbatim** (ADR-0021 E10): the ladder, the ≥3-token requirement,
+ the entity-boundary rule, the disclosure that identity rests on a name match **without ЕГН**, the homonym
+ and seat caveats, the lookup date, the refresh cadence and the retention. E10's existing promise that
+ held links „се показват едва след като регистърът стане достъпен" is finally kept by this change.
+- **The PII rails are asserted, not assumed** — over the shipped dump and over the loader's own output.
+
+## Consequences
+
+- The surface roughly triples, and 264 links that are withheld today become named public claims. The
+ failure mode of the whole change is a **wrong-company** attribution, not a wrong publication decision.
+- `tr-census.mjs` and the open-data census pipeline of ADR-0015 are removed. Its premise — that a name
+ proven nationally unique identifies the company — is the premise this ADR abandons.
+- The pipeline gains a hard dependency on an external registry. Every path that lacked one before now has a
+ fail-closed branch: no cache, partial cache, unknown legal form, rate limit.
+- We accept a slower, more fragile refresh in exchange for evidence. Decisions stay a pure, zero-network
+ function of declarations, cached deeds and contracts, but they no longer run on the 6-hourly ETL cycle
+ (#279 §9 assumes they do; they do not — the loader needs `node:sqlite`, the declaration corpus and the
+ full winner set, none of which exist inside a Worker). Cadence becomes two scheduled workflows: decisions
+ daily, registry lookups monthly. **Superseded by [ADR-0034](0034-registry-lookups-and-decisions-share-one-monthly-run.md):**
+ the split rests on the decision run being able to work from the cache alone, and it cannot — the strongest
+ rung compares the declarant's name against the deed text itself, which the index deliberately does not
+ store, so the raw deeds must be present; and they must not survive the runner. One monthly job, therefore.
+ Nothing else in this ADR is affected.
+- Two capabilities are deliberately left on the table and become separate changes under new rules versions:
+ as-of evidence via `?entryDate=`, and the §7 „и към днешна дата" labels with their post-period contracts.
+- The seat rung is structurally capped and we accept the cap: a declared seat is captured **only** from the
+ ООД/ЕООД holdings table of an *asset* declaration (`parse.mjs`), so it can never rescue a link declared
+ solely in an interests declaration. This is probably consistent with the 102 measured for the seat leg
+ specifically (not the 105 of the whole rung, whose other three come from the ЕИК leg) — rung 1 bars the
+ joint-stock cases anyway — but it is confirmed against the corpus before launch, not assumed.
+
+### Measured outcome — recorded on completion
+
+This ADR is accepted on the design. Exactly one amendment is permitted afterwards: this subsection is
+filled in with the measured result, and nothing else in the file is rewritten (repo convention — an
+accepted ADR is superseded, not edited). To be recorded:
+
+- the realized ladder split against the #279 §10 control row **as corrected above** (281 / 105 / 156 / 21 /
+ 4 / 4 = 571), with each number naming the SQL that produced it, and the gap between identified (281 + 105)
+ and surfaced (329) attributed bucket by bucket — 57 links on the corrected reading, 54 on the row as
+ written, so the figure itself distinguishes the two;
+- the true candidate-ЕИК count (#279's „~400" is an assumption; the figure is `COUNT(DISTINCT eik)` over
+ all links, not just published ones);
+- the hand-labelled sample result;
+- the marginal effect of the registry joint-stock bar over `closelyHeldForm`, and the count of unknown
+ `legalForm` codes encountered.
diff --git a/docs/adr/0034-registry-lookups-and-decisions-share-one-monthly-run.md b/docs/adr/0034-registry-lookups-and-decisions-share-one-monthly-run.md
new file mode 100644
index 00000000..c9afd007
--- /dev/null
+++ b/docs/adr/0034-registry-lookups-and-decisions-share-one-monthly-run.md
@@ -0,0 +1,63 @@
+# ADR-0034 — Справките в ТР и решенията текат в едно месечно задание
+
+- **Статус:** Прието
+- **Дата:** 2026-08-11
+- **Обхват:** конвейерът за свързани лица — `related-persons-data.yml`, `scripts/cacbg/load.mjs`,
+ `scripts/tr/fetch-deeds.mjs`. Заменя единствено твърдението за каденцията в последствията на
+ [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md); всичко останало в него стои.
+
+## Контекст
+
+ADR-0033 записа каденцията така: **„решения ежедневно, справки в регистъра месечно"**. Разделянето
+почиваше на едно предположение — че решаващият ход може да работи само върху кеша, а обхождането е
+отделен, по-рядък ход. Прегледът на PR-а показа, че това предположение не се държи.
+
+Двете страни на противоречието:
+
+1. **Суровите актове не бива да преживяват изпълнителя.** Кешираният акт носи имена на трети лица —
+ съдружници и управители без публична длъжност, и адреса на дружеството. Пренасянето му между ходове
+ изисква хранилище (кеш на Actions или артефакт), чието изгонване е политика за капацитет, не за
+ задържане: 35-дневният срок по решение 5 на ADR-0033 се изпълнява от `purgeExpired` на изпълнителя и
+ няма никаква власт над копие в хранилището на GitHub. Това беше находка B3 на прегледа.
+2. **Решението не може да се вземе без суровите актове.** Най-силното стъпало сравнява трите имена на
+ декларатора със самия текст на записа. Индексът на кеша нарочно не пази нито едно име (§9), така че
+ „кеширай само индекса" премахва точно данните, от които `evidenceVerdict` се нуждае.
+
+Двете заедно значат: **всеки ход, който решава, трябва и да обхожда.** А ежедневно решение тогава значи
+~400 заявки дневно към чужд публичен регистър, което спец. §3.3 („НИКОГА не обхождай ТР масово";
+КЗЛД, CJEU C-200/23) не разрешава да се приема с лека ръка — ограничителят на регистъра е единственият
+начин операторът да изрази предпочитание за темп и ние не се настройваме около него емпирично.
+
+## Решение
+
+**Едно задание, месечно.** `related-persons-data.yml` изпълнява последователно: зареждане на списъка с
+кандидати (`load.mjs --emit-candidates`, върху захвърляемо копие) → обхождане на регистъра → решение и
+публикуване (`load.mjs`) → одит (`audit.mjs`) → изтриване на суровите актове в стъпка `if: always()`.
+`scratch/tr` **не се кешира никога**; кешира се само `scratch/cacbg/raw` (публични декларации).
+
+`related-persons-tr-refresh.yml` е премахнат — нямаше какво да разделя.
+
+**Отхвърлена алтернатива: пренасяне на актовете между ходове** (кеш или артефакт с правило за
+жизнен цикъл). Работи технически, но връща B3: срокът на задържане престава да е наш и става политика на
+чуждо хранилище. R2 със собствено правило за изтриване остава разумен вариант, ако някога се наложи —
+днес не се налага.
+
+**Отхвърлена алтернатива: ежедневно решение.** Единственият начин да се запази е обхождането да издава
+**присъди по (връзка, ЕИК)** — булеви стойности без нито едно име — така че само те да пресичат
+границата между ходовете. Това е коректната посока и остава на масата, но е промяна в дизайна на
+доказателствения слой, не настройка на график, и не влиза в тази промяна.
+
+## Последствия
+
+- Публикуваната повърхност се преизчислява **месечно**, не ежедневно. Практическата цена е по-малка,
+ отколкото звучи: входът също се движи бавно (декларациите се подават годишно, вписванията в ТР —
+ рядко), а датата на справката се показва на всяка връзка, така че давността е видима за читателя, а не
+ подразбираща се.
+- **Спешна корекция не чака графика.** Свалянето на връзка минава през ADR-0031 и ръчния ход, а при
+ жива експозиция — през пряк `UPDATE` върху обслужващата D1; вж.
+ [ръководството](../runbooks/related-persons-suppression.md). Каденцията определя кога се преоткриват
+ връзки, не кога може да се махне една.
+- Едно задание значи един режим на отказ: ако обхождането спре при 429, гейтът за покритие на `load.mjs`
+ отказва частичния кеш и ходът не публикува нищо. Това е желаната посока — по-скоро без обновяване,
+ отколкото с орязана повърхност — и е същият гейт, който вече пазеше разделения вариант.
+- #279 §9 продължава да описва ежедневни решения. Този ADR е записът, че не е така, и защо.
diff --git a/docs/adr/0035-registry-evidence-must-also-establish-the-company.md b/docs/adr/0035-registry-evidence-must-also-establish-the-company.md
new file mode 100644
index 00000000..4ba01f70
--- /dev/null
+++ b/docs/adr/0035-registry-evidence-must-also-establish-the-company.md
@@ -0,0 +1,81 @@
+# ADR-0035 — Регистровото доказателство трябва да установи и ДРУЖЕСТВОТО, не само лицето
+
+- **Статус:** Прието
+- **Дата:** 2026-08-12
+- **Обхват:** доказателственото стъпало 2 („Документ") — `scripts/tr/evidence.mjs`, `scripts/cacbg/load.mjs`.
+ Заменя единствено твърдението в решение 2 на
+ [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md), че стъпало 2 не носи допълнителен
+ гейт; всичко останало в него стои.
+
+## Контекст
+
+ADR-0033 записа за стъпало 2: „по-силното стъпало не е гейтнато — регистърът е назовал това лице в ТОВА
+дружество, което прави ключа по име без значение". Прегледът на PR-а показа, че изводът важи само ако
+„ТОВА дружество" е дружеството, което лицето е декларирало. Не е гарантирано.
+
+Веригата, по която се стига до акта:
+
+1. `resolveEntity` (`load.mjs`) свежда декларираното фирмено наименование до **едно ЕИК на ПЕЧЕЛИВШ** —
+ участник в обществена поръчка. Дружества, които никога не са участвали, не съществуват за резолвера.
+2. `nameGloballyUnique` проверява уникалност **само в множеството на участниците**, не в Търговския
+ регистър. „Единствено" тук значи „единственото сред печелившите".
+
+Оттук следва сценарият, който прегледът описа и който възпроизведохме:
+
+- Длъжностното лице действително притежава «АЛФА ООД» с ЕИК **A**, което никога не е участвало в поръчка.
+- Друго, несвързано «АЛФА ООД» с ЕИК **B** е печеливш и единственият участник с това наименование.
+ Резолверът избира **B**.
+- Актът на **B** съдържа **съименник** на декларатора — същите три имена, което в България не е рядкост.
+ Стъпало 2 съвпада и публикува връзка, **невярна и в двете си половини**.
+
+Регистровото доказателство доказва, че *някой с това име* е съдружник в **B** — не че *това лице* е.
+Тоест стъпалото **премести** риска от съвпадение на имена в регистъра, вместо да го затвори, а точно
+затварянето му е основанието на #279. Съществуващият карантинен механизъм за двусмислени ключове покрива
+само сблъсък печеливш-срещу-печеливш; случаят печеливш-срещу-непечеливш не беше нито обработен, нито
+записан като приет остатъчен риск.
+
+## Решение
+
+**Преди стъпало 2 да твърди, нещо извън фирменото наименование трябва да каже, че дружеството е
+декларираното.** Съвпадението по име остава необходимо, но вече не е достатъчно само по себе си.
+
+Приема се, ако е изпълнено **поне едно** от:
+
+| Потвърдител | Защо стига |
+|---|---|
+| деклариран **ЕИК** | националният идентификатор по ЗТРРЮЛНЦ решава дружеството еднозначно, независимо от споделена фирма (ADR-0028) |
+| декларирано **седалище**, съвпадащо с вписаното | близнак в друго населено място отпада; същият факт, на който публикува стъпало 3, включително темпоралната гаранция R10 |
+| **отличително** фирмено наименование (`nameDistinctiveness`) | национален близнак е малко вероятен изобщо да съществува |
+
+Съвпадение по име, което не мине гейта, връща **нов, отделен въздържащ се резултат
+`document_uncorroborated`** — никога не публикува, не носи роля и не носи `matched_fact`.
+
+**Защо отделен вид, а не пропадане към `unknown`.** „Съвпаднахме лице, но не установихме дружеството" и
+„не съвпаднахме нищо" са различни факти за една връзка. Печатът се пише за всяка връзка, включително
+задържаните, именно за да е прегледаема опашката (migration 0006); сливането им я заслепява точно там,
+където решението има нужда от числото.
+
+**Третият потвърдител е граница, не доказателство.** Затова се **брои** (`document_uncorroborated` в
+обобщението на зареждането) — това е числото, което F8 чете, за да реши дали гейтът да се стегне до
+първите два.
+
+**Отхвърлена алтернатива: строго потвърждаване** (само ЕИК или седалище). По-висока точност, но
+декларирано седалище се извлича само от таблицата ООД/ЕООД на **имуществена** декларация — никога от
+декларация за интереси — така че цената в покритие е неизвестна преди F8 и можеше да свали повърхността
+под прага за публикуване. Остава на масата и е точно решението, което измереният остатък ще информира.
+
+## Последствия
+
+- Стъпало 2 вече е **гейтнато**, което ADR-0033 решение 2 изрично казваше, че не е. Това е единственото
+ твърдение оттам, което този ADR заменя; наборът от стъпала, редът им и правилото „първото съвпадение
+ печели" стоят.
+- Гейтът се проверява по подразбиране **затворено**: `companyNameDistinctive` е `false`, ако не бъде
+ подадено. За разлика от `nameGloballyUnique` — чието разрешаващо подразбиране е ограничено до
+ най-слабото стъпало — този гейт пази основното публикуващо стъпало.
+- Стъпала 2 и 3 споделят **една** реализация на съвпадението по седалище (`matchDeclaredSeat`), заедно с
+ R10. Две копия на „какво брои за съвпадение по седалище" рано или късно се разминават за това кои връзки
+ могат да се публикуват.
+- Покритието пада с неизмерена величина — по построение точно върху връзките, за които дружеството е
+ най-слабо установено. Това е приетата посока: по-скоро задържана вярна връзка, отколкото публикувана
+ невярна, назоваваща действително лице.
+- Методологичната страница оповестява правилото дословно (ADR-0021 E10).
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 0bd84ab6..0d63413f 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -16,15 +16,15 @@
| [0006](0006-eop-wins-dedup.md) | Dedup на два източника: EOP печели по `contract_number` | Прието |
| [0007](0007-scope-and-certainty-bar.md) | Свързани лица: обхват и праг на сигурност (само 100% детерминистични съвпадения) | Прието |
| [0008](0008-deterministic-name-to-eik-resolution.md) | Детерминистично разрешаване име→ЕИК (авто-публикуване; ръчна опашка само при двусмислие) | Прието |
-| [0009](0009-name-uniqueness-guard-and-publish-tiers.md) | Пазач за уникалност на името + нива на публикуване (A_seat / B_distinctive / C_hold) | Прието |
+| [0009](0009-name-uniqueness-guard-and-publish-tiers.md) | Пазач за уникалност на името + нива на публикуване (A_seat / B_distinctive / C_hold) | Заменено от [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) |
| [0010](0010-pii-posture.md) | Позиция за лични данни: без ЕГН/адреси; третите лица — само за вътрешна проверка | Прието |
| [0011](0011-host-scoped-tls-pinning.md) | TLS pinning само за хоста (счупена верига на register.cacbg.bg), не глобален байпас | Прието |
| [0012](0012-crawler-and-persistence-architecture.md) | Архитектура на обхождането и съхранението (resumable; кеш по xml_file + ControlHash) | Прието |
| [0013](0013-two-declaration-templates.md) | Два шаблона декларации (имущество + интереси) в един парсер | Прието |
| [0014](0014-match-output-layers-and-interpretation.md) | Слоеве на съвпадението и тълкуване (собственост/контрол, времеви, собствена институция) | Прието |
-| [0015](0015-tr-name-uniqueness-census.md) | Преброяване за уникалност на имена от ТР — промотира глобално уникални tier-C връзки | Прието |
+| [0015](0015-tr-name-uniqueness-census.md) | Преброяване за уникалност на имена от ТР — промотира глобално уникални tier-C връзки | Заменено от [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) |
| [0016](0016-free-text-entity-resolution.md) | Разрешаване на субекти от свободен текст (деклариран ЕИК + извличане от проза) | Прието |
-| [0017](0017-name-collision-tier-gate.md) | Гейт срещу колизия на имена извън отличаващото ниво | Прието |
+| [0017](0017-name-collision-tier-gate.md) | Гейт срещу колизия на имена извън отличаващото ниво | Заменено от [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) |
| [0018](0018-folder-discovery-and-republication-dedup.md) | Откриване на папки от индекса + dedup на препубликувани декларации (ControlHash) | Прието |
| [0019](0019-private-interest-vs-ex-officio-classification.md) | Разделяне на частен финансов интерес от служебни борд-роли (multi-declarant tell) | Прието |
| [0020](0020-conflict-explorer-surface-posture.md) | Повърхност на експлорера — само interest_links, noindex до одобрение, произход на всеки ред | Прието |
@@ -40,5 +40,8 @@
| [0030](0030-family-ownership-withheld-nameless-aggregate.md) | Дял на свързано лице не влиза в поименната повърхност (v1): събира се и се одитира, но се отчита само като безименен сбор (GDPR C-37/20; заменя ADR-0023) | Заменено от [ADR-0032](0032-family-ownership-published-under-public-interest.md) |
| [0031](0031-suppressions-version-controlled-fingerprinted.md) | Свалянията на връзки са версиониран списък с HMAC-отпечатък (salt = CI secret), прилаган при зареждане — не служебна таблица; не изтича сигнала „кой е свален" към прод | Прието |
| [0032](0032-family-ownership-published-under-public-interest.md) | Дял на свързано лице се публикува на поименната повърхност наравно със собствения — на основание надделяващ обществен интерес (ЗДОИ чл. 41и; C-184/20 забранява само поименните данни); близкият никога не се назовава, връзката не се твърди; заменя ADR-0030 | Прието |
+| [0033](0033-registry-evidence-replaces-name-distinctiveness.md) | Връзка се публикува само срещу проверим факт от Търговския регистър — доказателственият ред заменя нивата по отличителност на името; евристиката вече обосновава твърдение (изрично изключение от ADR-0007), барът за акционерни форми е обединение от три сигнала, монотонността е гейт, не хранилище; заменя ADR-0009/0015/0017 | Прието (дизайн) |
+| [0034](0034-registry-lookups-and-decisions-share-one-monthly-run.md) | Справките в ТР и решенията текат в едно месечно задание — суровите актове не преживяват изпълнителя, а решението не може да се вземе без тях; заменя твърдението за каденцията в ADR-0033 | Прието |
+| [0035](0035-registry-evidence-must-also-establish-the-company.md) | Стъпало 2 („Документ") публикува само ако извън фирменото наименование нещо установи, че дружеството е декларираното — деклариран ЕИК, съвпадащо седалище или отличителна фирма; иначе `document_uncorroborated`, което се брои; заменя твърдението в ADR-0033, че стъпало 2 не е гейтнато | Прието |
Свързан проектен документ: [spec/related-persons-foundation.md](../spec/related-persons-foundation.md).
diff --git a/docs/core-scope.md b/docs/core-scope.md
index ccf892fa..b1edd55d 100644
--- a/docs/core-scope.md
+++ b/docs/core-scope.md
@@ -58,7 +58,7 @@
| `review` | ≥10× процедурната оценка — задържан, но маркиран. | реалната EUR стойност |
| `value_low` | Нула/отрицателна, или дребна (<1000 EUR **и** <5% от оценката) — задържан, но маркиран. | реалната EUR стойност |
| `value_suspect` | >2 млрд. EUR, или >200× оценката (при оценка ≥1000 EUR) — **repair-ва се до процедурната оценка**. | процедурната оценка в EUR |
-| `annex_suspect` | Анекс е вдигнал стойността ≥100× или до отрицателна — пада към `signing`/`current`. | `signing`/`current` в EUR |
+| `annex_suspect` | Анекс е вдигнал стойността ≥100× или до отрицателна; или стъпка ≥10× при сбор ≥5× — пада към `signing`/`current`. | `signing`/`current` в EUR |
> **Кога `amount_eur` е `NULL`** (и редът е изключен от сумите): когато няма надеждна EUR стойност —
> чуждовалутен ред без покрит ECB курс (липсва latest-prior rate до 10 дни преди подписването),
diff --git a/docs/deploy.md b/docs/deploy.md
index ac3e845c..e296eda8 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -170,8 +170,17 @@ domain таблиците и преизчислява rollup-ите + FTS.
> [scripts/precompute.sql](../scripts/precompute.sql) — не изпращайте FTS съдържание през dump.
> Промени в схемата след първото зареждане се прилагат out-of-band, за всяка среда:
-> `SIGMA_D1_NAME=sigma-stage wrangler d1 migrations apply sigma-stage --remote`. Деплоите не мигрират
-> и не презареждат данни.
+> `SIGMA_D1_NAME=sigma-stage wrangler d1 migrations apply sigma-stage --remote`. Деплоите **не презареждат
+> данни**.
+>
+> **Уточнение (беше неточно):** тук пишеше и „деплоите не мигрират". Вече не е вярно —
+> [deploy.yml](../.github/workflows/deploy.yml) прилага изрично `0003`, `0009` и `0010` с
+> `d1 execute --file` при всеки деплой. Причината е, че ledger-ът на wrangler на тези бази е празен
+> (базовата схема е създадена out-of-band), тъй че `d1 migrations apply` би се сблъскал с `0000`. И трите
+> са безопасни за повторно прилагане: `CREATE … IF NOT EXISTS` и, при `0010`, тригери и индекси, които
+> се пресъздават до същото състояние. Отделно от тях има и стъпки, които сондират таблицата и добавят
+> само липсващите колони — за `0002` и за колоните на анексите. Останалите схемни промени наистина си
+> остават out-of-band.
## 3. Конфигуриране на GitHub Environments
diff --git a/docs/etl.md b/docs/etl.md
index 6c624620..4f34a507 100644
--- a/docs/etl.md
+++ b/docs/etl.md
@@ -116,12 +116,27 @@ node scripts/load-eop.mjs --from=YYYY-MM-DD --to=YYYY-MM-DD --no-ocds
Първоначалният backfill и ежедневният refresh ползват едни и същи staging таблици, mapper-и и
SQL. Различават се по прозореца от дати и режима на derive:
-- **голямо или първоначално догонване:** CLI прозорец + пълен derive;
-- **малък steady-state refresh:** gap-aware прозорец + slice derive.
+- **първоначално зареждане или пълно презареждане:** прозорец от началото на емисията + пълен derive;
+- **догонване и steady-state refresh:** gap-aware прозорец + slice derive.
`--derive=full` пуска amendment rollup, FX, NUTS, пълна нормализация и precompute. `--derive=slice`
-пуска scoped refresh SQL-а. По подразбиране catch-up логиката избира full derive за големи
-празнини и slice derive за малки.
+пуска scoped refresh SQL-а.
+
+Пълният derive **презижда** доменните таблици от staging — `normalize-raw.sql` започва с
+`DELETE FROM contracts` — тоест всичко извън заредения прозорец отпада и не се връща. Затова той е
+допустим само когато прозорецът стига до началото на емисията (или когато още няма корпус). Понеже
+gap-aware прозорецът по устройство покрива само опашката, `--catchup` върху **вече зареден** корпус
+прави slice derive, колкото и голяма да е празнината. Изключението е първото пускане: когато няма
+никакви заредени дни, догонването взима прозорец от началото на емисията и пълен derive — там няма
+какво да се загуби.
+
+Пълен derive с частичен прозорец върху вече зареден корпус `import.mjs` отказва да продължи — преди
+зареждането — вместо да изтрие историята. Отказът важи за **действащия** режим, не само за изрично
+подадения: без `--catchup` подразбиращият се derive е `full`, тъй че и `import.mjs --from=2026-06-01`
+получава същия отказ. Проверката пита за всяка таблица, която `normalize-raw.sql` изпразва (виж
+`@full-clear` там), не само за `contracts` — корпус без договори, но с попълнени `tenders` или
+`bidders` е точно състоянието, което половинчат пробег оставя. Ако някоя от тях не може да бъде
+прочетена, отказът пак важи: проверка, която не може да провери, не бива да пуска нататък.
## Доменна нормализация (derive)
@@ -215,6 +230,14 @@ CZK). `normalize-raw.sql` пази нативната записана стой
присвоява verdict `value_flag` и безопасната за сумиране `amount_eur` на всеки доменен договор.
Всички прагове се сравняват в евро след FX нормализация.
+> **Изключение — prefer-EOP dedup на анексите (#286).** `derive-amendments.sql` / `refresh-slice.sql`
+> **изтриват** OCDS редове от `raw_amendments`, когато EOP близнак покрива същия `(unp, contract_number)`.
+> Това е единственият пазач срещу двойно броене, защото промоцията в `amendments` е безусловна — затова
+> DELETE-ът, а не read-time филтър. Инвариантът е записан и се налага от integrity проверката
+> `amendment-twin-dedup` (`scripts/integrity-checks.mjs`): нито една двойка `(unp, contract_number)` не
+> носи едновременно EOP и OCDS сервиран ред. По slice пътя близнакът може да се раздели между прозорци,
+> затова там dedup-ът сверява и кумулативната сервирана таблица (виж коментарите в `refresh-slice.sql`).
+
Verdict-ът се определя от две прогнозни стойности, като всеки флаг ползва тази, която избягва
*собствената* си посока на false positive:
@@ -230,8 +253,22 @@ Verdict-ът се определя от две прогнозни стойнос
CASE-ът се оценява по ред; печели първото съвпадение:
-1. **`value_suspect`** — записана стойност неправдоподобно висока: `eff > 2 000 000 000` **или**
- (`procEst >= 1000` **и** `eff > 200 * procEst`). Редът се **поправя, не се хвърля**: `amount_eur`
+1. **`value_suspect`** — записана стойност неправдоподобно висока. Три отделни повода, всеки от
+ които стига сам по себе си:
+ - `eff > 2 000 000 000` — извън всякакъв мащаб за български договор;
+ - `procEst >= 1000` **и** `eff > 200 * procEst` — общият праг за неправдоподобно надвишаване;
+ - **стотинки лента** (#247, #298): пропуснат десетичен знак вдига стойността точно ~100 пъти.
+ Хваща се на две места, защото грешката може да е спрямо коя да е от двете прогнози:
+ - спрямо **процедурната**: `procEst >= 1000` **и** `95 * procEst <= eff <= 105 * procEst`;
+ - спрямо **прогнозата по позиция**: `ownEst >= 1000` **и** `procEst >= 1000` **и**
+ `eff >= 10 * procEst` **и** `95 * ownEst <= eff <= 105 * ownEst`.
+
+ Лентата е тясна нарочно — измерването върху корпуса показва изолирано струпване при ~100x и
+ нула договора между 105x и 200x. Второто рамо иска и `eff >= 10 * procEst`, защото при рамкови
+ и единични цени прогнозата по позиция е ЕДИНИЧНА цена и цял call-off законно я надхвърля
+ стократно; условието за процедурата отсява точно тези случаи.
+
+ Редът се **поправя, не се хвърля**: `amount_eur`
става `procEst` (собственият документиран бюджет на поръчката), а показваната нативна сума става
нативната процедурна прогноза. Пада до NULL (изключва се) само когато процедурата няма прогноза, от
която да се поправи. Guard-ът `procEst >= 1000` пази редовете, чиято *прогноза* е грешката
@@ -243,11 +280,17 @@ CASE-ът се оценява по ред; печели първото съвп
големите легитимни рамкови call-off-и (малък дял от огромен таван, но голям в абсолютна стойност)
извън този флаг.
3. **`annex_suspect`** — анекс е тласнал `current_value` до отрицателна или до `>= 100x` подписаната
- стойност, докато подписаната е разумна. Договорът **се връща към подписаната стойност** за
- `amount_eur`; надутата текуща стойност се потиска.
-4. **`review`** — надхвърляне в сива зона: `eff >= 10 * procEst`. Запазен, флагнат и **брои се по
+ стойност, докато подписаната е разумна; или единична анексна стъпка е скочила `>= 10x`, а сборът е
+ свършил `>= 5x` над подписаната (сбъркано число в анекс — само стъпката не стига, защото има
+ вериги, при които по-късен анекс сваля грешката обратно под подписаната стойност). Договорът
+ **се връща към подписаната стойност** за `amount_eur`; надутата текуща стойност се потиска.
+4. **`annex_total_suspect`** (#305) — водещият анекс е удвоил договора в една стъпка (ЗОП чл. 116
+ ограничава единично изменение до +50%), а текстът на основанието не дава сигнал, по който
+ стойността да се поправи. Договорът **се връща към подписаната стойност**, както при
+ `annex_suspect`.
+5. **`review`** — надхвърляне в сива зона: `eff >= 10 * procEst`. Запазен, флагнат и **брои се по
face value**.
-5. **`ok`** — всичко останало, брои се по `eff`.
+6. **`ok`** — всичко останало, брои се по `eff`.
Водещият принцип е **поправка пред изключване**: само наистина невъзстановими редове напускат
тоталите. Където записаната стойност е недвусмислен боклук, заместваме с най-добрия документиран
@@ -286,11 +329,51 @@ cross-check показват изпуснати десетични запета
6. пуска `refresh-slice.sql`;
7. пуска reconciliation gate-а (#97, `runIntegrityChecks`) върху обслужвания D1 в стъпка
`integrity-gate` и проваля стъпката при дрейф (виж [`integrity-gate.md`](integrity-gate.md)).
- FX-проверката нарочно **не** е включена тук — Worker-ът не зарежда курсове (проследено в #154).
+ FX-проверката нарочно **не** е включена тук (проследено в #154). Старата обосновка „Worker-ът не
+ зарежда курсове" вече не важи — от #158 стъпката `load-fx` тегли липсващите курсове преди derive-а.
Worker-ът е нарочно ограничен до малък скорошен прозорец. Ако базата е далеч назад, логва, че
прозорецът е capped, и оставя голямото догонване на CLI-то (`pnpm run import --catchup`).
+### Лъжливата грешка „hung" при всеки успешен цикъл
+
+Всяка **успешно завършила** инстанция на Workflow-а оставя и запис от ниво `error`:
+
+> The Workers runtime canceled this request because it detected that your Worker's code had hung
+> and would never generate a response.
+
+Тя пада на същата милисекунда, на която инстанцията получава своя `end` — тоест **след** като
+всички стъпки са върнали `success` и изходът е записан. Извикването се отчита с
+`outcome: exception`, докато самата инстанция е `status: complete`. Нищо не се губи.
+
+Измерено на `sigma-etl-stage` (2026-08-06) с три инстанции, при които варирахме всичко, което е
+в наши ръце:
+
+| Пуск | Стъпки | Време | Неизчерпани тела на отговори | „hung" |
+| --- | --- | --- | --- | --- |
+| крон, прозорец от 6 дни | 30 | 91 s | да | да |
+| празен прозорец (`today` в бъдещето) | 5 | 1,6 s | да (един 403) | да |
+| един пълен ден | 30 | 26 s | нула | да |
+
+Продължителност, брой стъпки, обем към D1, изход от четенията и кой `return` се ползва — грешката
+е неизменна спрямо всяко от тях. Затова към днешна дата я третираме като страничен продукт на
+средата (`executionModel: "stateless"`), а не като дефект в нашия код.
+
+**Това е измерване, не присъда.** Обходът на четенията, направен заедно с тези опити, пропусна
+един жив теч в `packages/ingest/src/fx.ts` — тоест „проверихме всичко наше" не се оказа вярно.
+Отваряйте въпроса наново, ако: грешката спре да пада точно на `end` времето; появи се инстанция
+със `status: complete`, но с непълни стъпки; или се намери в наш код изчакване, което не приключва.
+
+**Следствие за наблюдението:** „няма грешки в таблото" не е показател за здраве на този Worker.
+Здравето се чете от `etl_refresh_complete` — записът, който `run()` оставя след успешното
+изчистване на преходния staging. Точната истина винаги може да се провери и през
+`GET /accounts/{acct}/workflows/{name}/instances/{id}`, който дава списъка със стъпките.
+
+Предупреждението „An RPC result was not disposed properly" се появява само при крон-цикли с
+многодневен прозорец и не се възпроизведе при контролираните еднодневни пускания. В `apps/etl` и
+`packages/ingest` няма нищо, което държи RPC резултат, дължащ `dispose` — тоест източникът е под
+нас, в свързването.
+
**Текущо ограничение на Worker-а:** той стейджва само вложения OCDS файл. Coercion-ът на плоския
базов JSON все още живее в CLI loader-а и трябва да се извади в споделени Worker-safe helper-и,
преди Worker-ът да стейджва и базови contracts, tenders и annexes. Големите backfill-и остават
diff --git a/docs/implementation-plans/286-ocds-amendment-unp.md b/docs/implementation-plans/286-ocds-amendment-unp.md
new file mode 100644
index 00000000..e21755d4
--- /dev/null
+++ b/docs/implementation-plans/286-ocds-amendment-unp.md
@@ -0,0 +1,179 @@
+# Implementation Plan: #286 — OCDS amendments don't link to any contract (OCID instead of УНП)
+
+## Executive Summary
+
+| Field | Value |
+|---|---|
+| Ticket | [midt-bg/sigma#286](https://github.com/midt-bg/sigma/issues/286) — labels `data-quality`, `etl`, `priority: high` |
+| Problem | OCDS-sourced rows in `amendments` store the **OCID** (`ocds-e82gsb-245534`) in `unp` instead of the **УНП** (`00044-2022-0146`), so **none** of them join to a contract. Measured live on `sigma-dev`: 4,800 OCDS rows, **0 linked**. |
+| Root cause | `packages/ingest/src/ocds.ts` sets `unp: rel.ocid`. The УНП is **not present anywhere structured** in the OCDS feed — it must be recovered by bridging OCDS `tender.id` → EOP `tenderId` (`raw_tenders.tender_id` / `tenders.eop_tender_id`) → УНП (`source_id`). |
+| Approach | (1) Ingest captures `tender_ext_id` + fixes value semantics; (2) SQL bridge rewrites the OCDS `unp` from the existing lots-bridge pattern; (3) prefer-EOP dedup so twins don't double-count and the value trap can't fire. |
+| Complexity | Medium — small code, but the correctness traps (value semantics + cross-source dedup) are the whole point. |
+| Risk | Medium — touches the value pipeline that feeds headline `current_value`; a naive fix understates contracts by millions (issue's €5.8M case). Mitigated by parts 2+3 shipping together and a real-corpus before/after. |
+| Status | **Draft — validated against the live `sigma-dev` corpus.** |
+
+> **The two halves are inseparable.** Fixing the join key *without* fixing the OCDS value semantics is worse than the bug: once linked, the stale OCDS "before" value competes with the correct EOP value on the same `published_at` and the tiebreak can pick it. Key-alignment **and** value-correctness land in the same PR.
+
+---
+
+## 1. Problem, verified on real data
+
+The УНП is genuinely absent from the OCDS releases. Pulling the real feed
+`storage.eop.bg/open-data-2026-03-05/…OCDS.json` and walking all 28
+`contractAmendment` releases: the `NNNNN-YYYY-NNNN` УНП pattern appears **zero
+times** as a structured field (once, incidentally, in free-text `rationale`). A
+real release carries only:
+
+- `ocid: "ocds-e82gsb-425867"` and `tender.id: "425867"` (the EOP internal
+ procedure id = the OCID suffix)
+- `contracts[].id: "180821"` (the contract number)
+- `buyer.identifier.id: "000024948"` (authority EIK)
+- `contracts[].value` — the **pre-amendment** value
+
+The EOP base "договори" feed, by contrast, carries **both** join anchors:
+`uniqueProcurementNumber` (→ `unp`, the УНП) **and** `tenderId` (→
+`tender_ext_id`, the same id space as OCDS `tender.id`).
+
+### Reproduction on `sigma-dev` (live)
+
+| check | count |
+|---|---|
+| `amendments` rows with `source LIKE 'ocds:%'` | 4,800 |
+| …with `unp LIKE 'ocds-%'` | 4,800 (all) |
+| …that link via `contracts.tender_id = 't:' \|\| unp` | **0** |
+| `eop:%` rows that link | 174,635 |
+
+### The value trap (contract 90029, live)
+
+| source | unp | value_before | value_after | published_at |
+|---|---|---|---|---|
+| `eop:annexes` | `00044-2022-0146` | 21,602,081.98 | **27,435,415.31** | 2026-03-05 |
+| `ocds:` | `ocds-e82gsb-245534` | null | **21,602,081.98** | 2026-03-05 |
+
+`ocds.ts:378-379` writes `value_after = c.value.amount` (the *before* value) and
+`value_before = null`. `derive-amendments.sql:42-50` selects `current_value` as
+the latest non-null `value_after` with `ORDER BY published_at DESC, natural_key
+DESC`. Same date ⇒ the tiebreak can pick OCDS's 21.6M and drop €5.8M. Twin
+samples confirm OCDS values are generally stale/partial (e.g. contract 188980:
+OCDS 111,163 vs EOP 217,416), so the OCDS value is never trustworthy as an
+"after".
+
+---
+
+## 2. Why the bridge is the right recovery (proven in-repo + on data)
+
+The exact bridge already exists for OCDS **lots** —
+`scripts/normalize-raw.sql:1110-1133`:
+
+```
+-- The bridge is OCDS tender.id -> EOP tenderId (raw_tenders.tender_id) -> UNP -> domain lots.
+-- ocid is a surrogate and is never treated as the UNP.
+JOIN raw_tenders rt ON rt.tender_id = rl.tender_id
+```
+
+OCDS **amendments** never got the same treatment: `releaseToAmendments` doesn't
+read `rel.tender.id`, even though `raw_amendments.tender_ext_id` already exists
+as a column (`work-staging-schema.sql:183`) and EOP amendments populate it
+(`base.ts:300`).
+
+Read-only validation of the bridge against `sigma-dev` (all OCDS `unp` are
+`ocds-e82gsb-`; `tenders.eop_tender_id` maps to `source_id`/УНП):
+
+| step | count |
+|---|---|
+| OCDS amendments whose `tender.id` bridges to a tender (УНП recovered) | 4,797 / 4,800 |
+| …that link to a contract with matching `contract_number` | **4,782** |
+| …that have an **EOP twin** for the same `(unp, contract_number)` | 4,741 (~99%) |
+| genuinely **OCDS-only** annexes (net-new) | ~41 |
+
+So ~99% of OCDS amendments duplicate an existing EOP annex. Linking them without
+dedup would ~double `annex_count` on those contracts and arm the value trap.
+Per-annex twin matching is unreliable (twin dates and values differ), so dedup is
+done at the contract level: **prefer EOP; use OCDS only where EOP has no annex.**
+
+---
+
+## 3. The fix (three parts, one PR)
+
+> **Shipped implementation — where it diverged from the plan below (this section is the original
+> planning snapshot).** Part 2's bridge landed in `scripts/derive-amendments.sql` (mirrored in
+> `scripts/refresh-slice.sql`), **not** `normalize-raw.sql` — `derive-amendments.sql` is the *first*
+> consumer of `raw_amendments` on every path, so the rewrite has to happen there or its own rollup still
+> joins on the OCID. Part 3's dedup shipped as a `DELETE FROM raw_amendments` (not an inline
+> `WHERE NOT EXISTS` filter), and `promote-amendments.sql` is unchanged. On the incremental path the
+> slice dedup additionally reconciles against the cumulative served `amendments` table (full path rebuilds
+> it wholesale, so it needs no equivalent). See the review thread on the PR for the reasoning.
+
+### Part 1 — Ingest (`packages/ingest/src/ocds.ts`, `apps/etl` re-exports)
+- Add `tender_ext_id` to `AmendmentStagingRow` and `AMENDMENT_STAGING_COLS`.
+- In `releaseToAmendments`, set `tender_ext_id: clean(rel.tender?.id)`.
+- Value semantics: store the release value as `value_before`; leave
+ `value_after = null`. OCDS cannot know the after-value, so it must never drive
+ `current_value`. (Verify `contractUpdate` separately before treating it the
+ same as `contractAmendment`; default to the conservative before-only mapping.)
+- Unit tests updated in `packages/ingest/src/ocds.test.ts`.
+
+### Part 2 — SQL bridge (`scripts/normalize-raw.sql`)
+- Before `derive-amendments.sql` / `promote-amendments.sql`, rewrite OCDS
+ amendment `unp` from the bridge, mirroring the lots block:
+ ```sql
+ UPDATE raw_amendments
+ SET unp = (
+ SELECT rt.unp FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL
+ )
+ WHERE source LIKE 'ocds:%'
+ AND tender_ext_id IS NOT NULL
+ AND EXISTS (SELECT 1 FROM raw_tenders rt WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL);
+ ```
+ Fallback to `raw_contracts.tender_ext_id` where a tender row is absent. `ocid`
+ stays only as a surrogate, never a key. Mirror the same step in
+ `scripts/refresh-slice.sql` (the scoped slice path).
+
+### Part 3 — Prefer-EOP dedup (`derive-amendments.sql` + `promote-amendments.sql`)
+- Include an OCDS amendment only when no EOP amendment exists for the same
+ `(unp, contract_number)`:
+ ```sql
+ AND NOT (source LIKE 'ocds:%' AND EXISTS (
+ SELECT 1 FROM raw_amendments e
+ WHERE e.source LIKE 'eop:%' AND e.unp = raw_amendments.unp
+ AND e.contract_number = raw_amendments.contract_number))
+ ```
+ This kills the double-count and the value trap in one move while still
+ surfacing the ~41 OCDS-only annexes. OCDS-only rows keep `value_after = null`,
+ so they increment `annex_count` and become visible/linked without inventing a
+ `current_value`.
+
+---
+
+## 4. Testing — proving we solved the real problem
+
+1. **Unit** (`packages/ingest/src/ocds.test.ts`): realistic fixture with
+ `tender.id` + value asserts `tender_ext_id` captured, `value_before` set,
+ `value_after` null.
+2. **End-to-end SQL** (`packages/db/src/refresh-slice.test.ts` harness runs the
+ *actual* `normalize-raw → derive-amendments → promote-amendments` scripts via
+ the sqlite3 CLI). New case seeds:
+ - an EOP tender+contract (УНП `X`, `tenderId T`) with one EOP annex,
+ - an OCDS twin (`ocds-…-T`, same `contract_number`),
+ - an OCDS-only annex (`tenderId T2`, no EOP annex).
+ Assert: OCDS-only links & counts once; twin is dropped; the 90029 scenario
+ keeps `current_value = 27.4M` (never 21.6M).
+3. **Repro regression**: encode the issue's SQL (`ocds:%` linked rows > 0,
+ no contract understated) as assertions.
+4. **Real-corpus before/after**: rebuild a local slice from the public feeds and
+ run the repro queries; optionally dry-run-validate the bridge against
+ `sigma-dev` read-only. Expected: linked OCDS rows 0 → ~41 net (4,782 matched,
+ 4,741 deduped), zero contracts understated.
+
+---
+
+## 5. Scope boundaries
+
+- **Out of scope:** OCDS *contracts* also store the OCID in `unp`
+ (`ocds.ts:323`); whether they reach the served domain at all is a separate
+ question — note it, don't fold it in.
+- **Adjacent:** #248 (annex plausibility) and PR #285 (`value_delta` sign) touch
+ the same table but are independent defects.
+- **Migration:** none — `raw_amendments.tender_ext_id` already exists; the change
+ is ingest + ETL SQL + tests only.
diff --git a/docs/implementation-plans/305-amendment-value-double-count.md b/docs/implementation-plans/305-amendment-value-double-count.md
new file mode 100644
index 00000000..7c67c492
--- /dev/null
+++ b/docs/implementation-plans/305-amendment-value-double-count.md
@@ -0,0 +1,119 @@
+# Implementation Plan: #305 — Amendment value double-count (a new *total* is added to the old value)
+
+## Executive Summary
+
+| Field | Value |
+|---|---|
+| Ticket | [midt-bg/sigma#305](https://github.com/midt-bg/sigma/issues/305) — labels `data-quality`, `etl`, `priority: high` |
+| Problem | When an EOP annex announces a new **total** contract value, ЦАИС ЕОП puts that total in the *change* field, so `currentContractValue = lastContractValue + newTotal`. Sigma stores `value_after` verbatim, so the served value is **doubled** (the old value is counted twice). |
+| Root cause | **Source data defect, faithfully stored.** `base.ts:313-315` maps `value_before ← lastContractValue`, `value_after ← currentContractValue`, `value_delta ← contractValueDifference` with **no arithmetic**. The bug is that, for a subset of annexes, `contractValueDifference` (→ `value_delta`) holds the **new total**, not the increment — and the feed's `currentContractValue` is `before + that total`. Verified: `value_after = value_before + value_delta` on **100%** of delta-carrying rows on `sigma-dev`. |
+| Why existing flags miss it | A double-count is only ~2× signed value. `value_flag = 'annex_suspect'` needs ≥5× aggregate (+ a ≥10× per-step); #299 the same; the estimate-based flags need ≥10×/≥200×. 2× < 5× ⇒ classed **`ok`** ⇒ enters the `amount_eur` canonical value base and inflates every rollup. (`normalize-raw.sql:882-948`.) |
+| Scale (real corpus) | On the issue's 2020→2026 local rebuild: 7,335 price-raising annexes, **686 at ≥100% growth**, ~666 unflagged, **€475.4M**. Independently reproduced on `sigma-dev`: **686 at ≥100% growth**, ~526 unflagged. The named records (145652, 189325, 84818) confirm. |
+| Complexity | Medium. Detection is the hard part (a source-text heuristic with false-positive risk); the plumbing (flag + exclude, then optional correct) mirrors the existing `annex_suspect` machinery. |
+| Risk | Medium. A naive correction that trusts a text heuristic can mis-restate genuine >100% increases; a flag-only tier is safe and ships first. The signature has a **blind spot** (a new-total *lower* than the old value hides below +100%), so the fix must not be sold as complete. |
+| Status | **Draft — investigated with real DB calls + code trace.** |
+
+> **Two independently-true facts frame the fix.** (1) The source is internally *consistent* — `value_after = value_before + value_delta` always — so the correction can be expressed purely as *"when `value_delta` is a **total**, the true `value_after` is `value_delta`, not `value_before + value_delta`."* (2) The defect is `value_flag = 'ok'`, so it is inside the aggregated value base; the minimal safe fix is to move it *out* of that base (a new verdict), exactly as #299 did for its case.
+
+---
+
+## 1. Problem, verified on real data
+
+### 1a. The mechanism (code, on `main`)
+- `packages/ingest/src/base.ts:313-315` — EOP annexes map three source keys straight to columns, no math:
+ - `value_before ← lastContractValue`
+ - `value_after ← currentContractValue`
+ - `value_delta ← contractValueDifference` (the only signed field)
+- `scripts/derive-amendments.sql:147-155` (mirrored in `scripts/normalize-raw.sql:752-759`): `contracts.current_value` = the latest amendment's non-null `value_after`. So the doubled `value_after` becomes the served contract value.
+- `scripts/promote-amendments.sql:42-44`: `value_before/after/delta` copied verbatim into served `amendments`.
+
+**Conclusion:** the doubled figure is not computed by Sigma; it arrives in `currentContractValue`. For the affected annexes the authority entered the **new total** into `contractValueDifference`, and the feed's `currentContractValue = lastContractValue + newTotal`. Sigma stores it faithfully.
+
+### 1b. The named records (queried on `sigma-dev`, read-only)
+
+| contract | source | value_before | value_after | value_delta | doubled? |
+|---|---|---|---|---|---|
+| 145652 (УНП 00010-2023-0006) | `eop:annexes:2024-06-21` | 442,000 | **981,240** | 539,240 | yes — `value_delta` (539,240) is the announced new total; true `value_after` = 539,240 |
+| 189325 (УНП 00210-2024-0024) | `eop:annexes:2025-10-07` | 77,000,000 | **154,000,000** | 77,000,000 | yes — exact 2× (currency-change annex) |
+| 84818 (УНП 00080-2023-0001) | `eop:annexes:2026-07-17` (EUR) | 76,769,540.87 | **153,539,081.74** | 76,769,540.87 | yes — exact 2× |
+
+Caveats found in verification: 84818 has **6** amendment rows (only the 2026-07-17 EUR annex is the doubled one — earlier BGN annexes are consistent); the issue treated it as one. Absolute counts differ from the issue because `sigma-dev` (31,543 amendments) is a superset of the issue's local rebuild (26,921).
+
+### 1c. The math signature and its blind spot
+- `value_delta = value_after − value_before` holds on **100%** of delta-carrying rows (`sigma-dev`), i.e. the source is self-consistent. The defect is semantic: `value_delta` is sometimes a *total*, not an *increment*.
+- A double-count where `newTotal ≥ before` produces growth **≥ 100%** (686 rows). The exactly-+100% subset (`value_after = 2×before`) is the "same total re-stated" / currency-change case.
+- **Blind spot:** if `newTotal < before`, the double-count yields growth **< 100%** and hides among clean rows. The +100% line is a *safety threshold*, not a proof of cleanliness — the fix must say so.
+
+---
+
+## 2. Why the existing flags don't catch it
+
+`scripts/normalize-raw.sql:882-948` (mirrored in `refresh-slice.sql`), evaluated top-down against `eff_eur = EUR(COALESCE(current_value, signing_value))`:
+- `value_suspect`: `eff_eur > 2e9`, or `> 200 × proc_est_eur`, or the стотинки band — estimate-relative, ignores a 2× overrun.
+- `annex_suspect` (`:937-945`, the #299/#248 rule): `current_value/signing_value ≥ 100`, **or** (`≥ 5` **and** a per-step `value_after ≥ 10 × value_before`). A double-count is ~2× signed ⇒ below 5×.
+- `review`: `eff_eur ≥ 10 × proc_est_eur`.
+- else `ok`.
+
+A ~2× inflation clears none of these gates → `ok` → `amount_eur` takes `COALESCE(current_value, signing_value)` (`normalize-raw.sql:818-830`), so the doubled value is summed everywhere. Pinned by `packages/db/src/value-flag-annex-step-sql.test.ts` (the 5× floor is the smallest firing case; 4.9× stays `ok`).
+
+**Downstream consumers currently inflated** (all via the shared `amount_eur` base — `precompute.sql:16-19`): contract-list totals/sort/buckets and CSV export (`queries/contracts.ts:318,61-62,432,475`); `company_totals.won_eur`, `authority_totals.spent_eur`, `home_totals.value_eur` (`precompute.sql:42-60`); the contract detail value strip and the **amendment timeline**, which reads `amendments.value_after` unrepaired (`queries/details.ts:451-460,670-684`). *(The `/anomalies` #239 and `/overruns` #171 signals named in the issue are not on `main` yet — they will inherit the fix once they land.)*
+
+---
+
+## 3. Detection
+
+The correction hinges on classifying each price-raising annex as **increment** vs **total**. Layer the signals; never rely on free text alone.
+
+1. **Arithmetic gate (necessary, cheap, high-recall / low-precision):** `value_before > 0 AND value_after ≥ 2 × value_before` (equivalently `value_delta ≥ value_before`). A single annex whose *increment* is ≥ the entire prior value is implausible; a double-count always lands here. Catches the 686. Does **not** catch the sub-100% blind spot (out of scope for v1, documented).
+2. **Text confirmation (raises precision):** the основание free-text carries a number equal to `value_delta` in a *total* context — keywords `обща|общата|крайна|краен|възлиза|става` near the figure (the issue found 355 such records). Available fields at the raw/derive stage: `raw_amendments.description` (`changeDescription`), `reason` (`changeReason`), `circumstances` (`changeReasonDescription`) — see §5 note. Parse Bulgarian number formats (`1 234 567,89` / `1234567.89`), compare to `value_delta` within a small relative tolerance.
+3. **Currency-change tell (special-case, very high precision):** exactly-+100% rows whose text mentions `евро|валута|EUR|лева в евро` are currency re-denominations with the total doubled (e.g. 189325). Treat as confirmed total.
+
+Classification:
+- **Confirmed total** = gate (1) AND (text (2) or (3)). → correct (Tier 2) and/or flag.
+- **Suspected total** = gate (1) only (no text confirmation). → flag-only (Tier 1); do not silently rewrite the value.
+
+---
+
+## 4. The fix
+
+Two tiers. Ship Tier 1 first (safe, immediate); Tier 2 is the higher-value correction and needs the text heuristic hardened by tests.
+
+### Tier 1 — Flag and exclude from aggregates (minimal, safe, ships first)
+Mirror the `annex_suspect` machinery so the ~475M/€ inflation leaves every rollup immediately, without trusting any text parse.
+- Add a new `value_flag` verdict, e.g. **`annex_total_suspect`**, in `scripts/normalize-raw.sql` (and the `refresh-slice.sql` mirror + its reconciliation re-flag), placed **before** the `ELSE 'ok'`: fires when the contract's current-value-driving annex satisfies the arithmetic gate (§3.1) and the source-consistency check (`value_after ≈ value_before + value_delta`).
+- Route it through the existing suspect fallback: `amount_eur`/`trusted_native` fall back to `signing_value` (`normalize-raw.sql:818-830`) and `precompute.sql:36` NULLs `current_value_eur` — so these contracts drop out of totals/CSV/pages exactly like `annex_suspect`.
+- Emit a diagnostic count (like #286's diagnostics) so the flagged volume is observable in the ETL log.
+- **Per-amendment flag (the issue's specific gap):** the contract flag does not fix the timeline row. Add a per-amendment marker so `queries/details.ts` can render the row as "suspected re-stated total" and suppress its `+%`. Options: a `value_flag`/`total_restated` column on served `amendments` (schema migration + carry through `promote-amendments.sql`), or recompute the same predicate in the details query. Prefer the column (single source of truth, avoids duplicating the heuristic in TS).
+
+### Tier 2 — Correct the value (higher value, needs text confirmation)
+For **confirmed totals** (§3), restate the amendment at the **raw/derive stage** (where `value_before`, `value_delta`, and all three text fields coexist — see §5):
+- Because the source is self-consistent, the correction is simply **`value_after := value_delta`** (the announced new total) and **`value_delta := value_after_old − value_before`** *no* — restate as: `corrected_after = value_delta_source` (the total); `corrected_delta = corrected_after − value_before` (the true increase). Keep the raw source values immutable in `raw_amendments`; write corrected values on the way to served `amendments` (a `promote`/derive transform), plus a `total_restated = 1` marker.
+- `contracts.current_value` then derives from the corrected `value_after`, so headline value, deltas, and the timeline are all right — not merely excluded.
+- Keep Tier-1 flagging for the **suspected-but-unconfirmed** set (gate only, no text) so nothing inflates while remaining un-restated.
+
+### Fix location (decided)
+Raw/derive stage, **not** ingest and **not** the served query layer:
+- Ingest (`base.ts`) must stay a faithful mirror of the source (per `docs/etl.md` non-destructive-staging stance) — do not mutate `raw_amendments`.
+- The correction/flag needs `value_before`, `value_delta`, and the основание text on one row, which is true in `raw_amendments` and consumed by `derive-amendments.sql` / `normalize-raw.sql` / `promote-amendments.sql`. Implement there; keep `derive-amendments.sql` and `refresh-slice.sql` in lockstep (a drift guard already exists for the #286 bridge block — extend the pattern).
+
+---
+
+## 5. Schema / data note (blocking for Tier 2)
+Only `description` survives to served `amendments`; `reason` and `circumstances` are dropped at `promote-amendments.sql:9-11` (served DDL `0000_init.sql:169-182`). The text heuristic therefore must run at the **raw/derive** stage where all three exist (`work-staging-schema.sql:178-180`). If any per-row flag or corrected value must be *visible* to the app, add the column(s) to the served `amendments` table (migration) and carry them through `promote-amendments.sql` + the `refresh-slice.sql` amendments promotion.
+
+---
+
+## 6. Testing — proving the fix
+1. **Unit — number/keyword parser** (`packages/ingest` or a new `packages/db` SQL-driven test): Bulgarian number formats; total-context keywords vs increment phrasing; the currency-change tell. Fixtures from the real examples (145652 "възлезе на 539 240.00 лв."; 189325 currency change; a genuine >100% *increment* that must NOT be corrected).
+2. **End-to-end SQL** (extend `packages/db/src/refresh-slice.test.ts` / a new `amendments-total-suspect.test.ts`): run the real `derive-amendments.sql → normalize-raw.sql → promote-amendments.sql` and assert: (a) a confirmed-total annex is restated (`value_after = value_delta`, `current_value` correct, `total_restated = 1`); (b) a suspected-only annex is flagged `annex_total_suspect` and excluded from `amount_eur`; (c) a genuine >100% increment stays `ok` and untouched (guard against false positives); (d) the exactly-+100% currency case restates to no real growth.
+3. **Flag-coverage regression**: assert the new verdict count on a seeded corpus, and that `value-flag-annex-step-sql.test.ts`'s existing cases are unchanged.
+4. **Real-corpus before/after**: rebuild the 2020→2026 corpus (local work-DB, then optionally ship), and report flagged/corrected counts + the EUR removed from `company_totals`/`authority_totals`/`home_totals`. Target: the 686 (≥100%) restated or flagged, headline totals drop by the double-counted amount, zero genuine-increment regressions.
+
+---
+
+## 7. Scope boundaries & risks
+- **In scope:** EOP annexes (the sole driver — OCDS rows carry `value_after = NULL` and never drive `current_value`, per #286).
+- **Blind spot (out of scope for v1, must be documented):** double-counts where the new total is *lower* than the old value (growth < 100%) — not detectable by the arithmetic gate. Text-only detection could reach them but at higher false-positive cost; defer.
+- **False-positive risk (the main hazard):** a genuine annex that legitimately more-than-doubles a contract. Tier 1 only *flags* (reversible, excludes from aggregates); Tier 2 *rewrites* and must require text confirmation + be unit-tested against a real >100%-increment fixture. When uncertain, prefer flag over rewrite.
+- **Adjacent / not this:** #299 (`c44a7ee`) and #248 handle the ≥10×/≥5× mis-keying case (kept); #245 (EUR double-conversion) and #304/#247 (стотинки) are separate. This defect is the sub-5× band those cannot reach.
+- **Consumer follow-through:** once `/anomalies` (#239) and `/overruns` (#171) land, confirm they read a value base that already excludes/corrects these (they will, if they use `amount_eur` + `value_flag`).
diff --git a/docs/implementation-plans/306-amendment-contract-namespace-link.md b/docs/implementation-plans/306-amendment-contract-namespace-link.md
new file mode 100644
index 00000000..3b23ff19
--- /dev/null
+++ b/docs/implementation-plans/306-amendment-contract-namespace-link.md
@@ -0,0 +1,94 @@
+# Implementation Plan: #306 — annexes don't link to a contract (contract number in a different namespace)
+
+## Executive Summary
+
+| Field | Value |
+|---|---|
+| Ticket | [midt-bg/sigma#306](https://github.com/midt-bg/sigma/issues/306) — labels `data-quality`, `etl`, `priority: high` |
+| Problem | 1,937 EOP annexes (7.2%) don't link to any contract. #286 fixed the OCDS half (procedure axis: OCID vs УНП). This is the other axis: the **contract number**. The annex carries an internal annex-side number (`148846`, `2886`); the contract carries the buyer's filing number (`Д-226`, `388-2020`). The exact `(unp, contract_number)` join fails, so the annex drops out of every annex→contract→company/authority rollup. |
+| Root cause | Two genuinely unrelated identifiers in different namespaces — not dirty strings. Measured: `TRIM`+`UPPER` saves 0, digits-only 11, substring 19. String normalisation is a dead end. |
+| Approach | Link by **value**: an annex's `value_before` is the contract's value at amendment time, so it equals the target contract's `signing_value`. Rewrite `raw_amendments.contract_number` to the resolved contract when `value_before` **exactly** (< 0.5 стотинка), currency-matched, uniquely matches one contract on the procedure; propagate across a chain sharing the annex-side number. |
+| Complexity | Medium — the resolver is a self-contained CTE; the subtlety is full-vs-slice parity (the #305 bug class) and the slice's windowed staging. |
+| Risk | Low on the full path (measured 99.99% precision), gated to leave ambiguous/no-match annexes unlinked. |
+| Status | **Full-derive resolver implemented as a gated, standalone script (`scripts/resolve-amendment-contracts.sql`) that runs BEFORE `derive-amendments.sql`, full-derive path only. Slice/daily resolver deferred (documented below).** Validated against the live `sigma-dev` corpus; hardened per the PR #308 review (ordering blocker, group-contradiction rule, cumulative-candidate dedup, EIK guard, provenance columns). |
+
+---
+
+## 1. Problem, verified on real data (`sigma-dev`)
+
+Reproduced the issue's breakdown exactly on the served corpus (an amendment `a` links iff `contracts c` exists with `c.tender_id = 't:'||a.unp AND c.contract_number = a.contract_number`):
+
+| check | #306 | live `sigma-dev` |
+|---|---|---|
+| EOP annexes unlinked | 1930 | **1937** |
+| empty contract_number | 0 | 0 |
+| empty УНП | 0 | 0 |
+| УНП not in `tenders` | 0 | 3 |
+| procedure has no contract | 12 | 15 |
+| procedure has contracts, none match | 1918 | **1919** |
+| procedure has exactly 1 contract (safe) | 1012 | **1012** |
+| price-changing | 554 | **555** |
+
+## 2. The value anchor (the insight beyond the issue)
+
+The issue proposed linking only the 1,012 single-contract procedures by УНП. Measuring `value_before` → `signing_value` unlocks the multi-contract cases too. On the **already-linked** annexes (ground truth), when `value_before` uniquely matches one contract's `signing_value`:
+
+| gate | unique matches | correct | precision |
+|---|---|---|---|
+| within 1% | 9061 | 8975 | 99.08% |
+| **exact (< 0.5 стотинка)** | **9349** | **9348** | **99.99%** |
+
+Exact-cent match wins on **both** precision and recall — the 1% band manufactures ambiguity and admits the #305 "matched a smaller sibling's value" errors. Example `00011-2020-0002` (multi-lot): the unlinked `2886` annexes carry `value_before=56000`, which exactly matches contract `388-2020` (lot 2, signing 56000), **not** `387-2020` (64000) — a case УНП-only linking cannot resolve.
+
+### Recovery under the shipped gate (exact + currency + unique + chain-propagation)
+
+| tier | rule | links |
+|---|---|---|
+| single-contract, value-confirmed | procedure has 1 contract, `value_before` == its `signing_value` | 775 |
+| multi-contract, value-unique | `value_before` exactly matches one of ≥2 contracts | 604 |
+| **combined (before chain propagation)** | | **~1,379 (71%)** |
+
+Left **unlinked by design**: value-ambiguous (matches 2+, ~115), no-match (~193, target not yet ingested — the #249 class), no-tender/no-contract (~18). An honest gap beats a wrong contract on a transparency site.
+
+> **Re-measured on the live `sigma-dev` corpus (PR #308 review).** Read-only reproduction against the served corpus confirms the headline numbers hold under the revised rule: **1,937** unlinked, **1,563** recovered (1,377 direct + 186 propagated), precision **9,348/9,349 = 99.99%**. Two review inferences were corrected by the data: the `value_before IS NULL/≤0` class is **empty** (all 1,937 unlinked annexes carry a value; the `1,379+326≠1,937` gap is tier-estimate rounding, not a value-less class), and the only anchor-disagreement group in the whole corpus is the correct 00026 lot-base case (hence the §3.4 rule revision). **Still owed:** the served corpus is deduped one-row-per-contract, so it does not exercise the cumulative-staging path (§3.1) — that must be re-measured on raw cumulative staging via a local full backfill before the dedup fix is fully validated end-to-end.
+
+## 3. Fix — full-derive path (`scripts/resolve-amendment-contracts.sql`, run before `derive-amendments.sql`)
+
+The resolver is a **standalone script** that `import.mjs` runs from `runFullDerive` / `runWorkBackfill` **before** `derive-amendments.sql` — i.e. **before** the #286 prefer-EOP dedup DELETE and its diagnostics. Ordering is load-bearing (PR #308 review, todorkolev #1 blocker): rewriting an EOP annex onto a contract that already kept an OCDS twin, if done *after* the dedup, resurrects the twin (`annex_count = 2` on a one-annex contract) and trips the `amendment-twin-dedup` integrity gate (#303), failing the whole derive. Running first — and above the #286 diagnostics — keeps the dedup the sole twin guard and its dropped/excess counts honest bounds. The resolver reads only `source LIKE 'eop:%'` rows + `raw_contracts`, so it is independent of the OCDS bridge and safe to run first.
+
+1. `contract_candidates` — `raw_contracts` **deduped to one row per `(unp, contract_number)`** (`ROW_NUMBER() … ORDER BY source DESC, id DESC = 1`), mirroring normalize-raw. `raw_contracts` is cumulative (the same contract recurs across daily buckets; the collapse happens later in normalize-raw), so without this `COUNT(*) OVER` would count staging **rows**, not contracts — mass fail-close on a real rebuild, or a match to a superseded value (PR #308 review, nikimilenkov HIGH 2).
+2. `grp` — **all** EOP annexes with no `(unp, contract_number)` contract, grouped by the shared annex-side number `(unp, annex_cnum)` = one contract's chain. Value-less members (`value_before` NULL/≤0) are included so they can inherit the chain target (review MEDIUM 2).
+3. `vmatch` — join to `contract_candidates` on `unp`, exact value (`ABS(signing_value − value_before) < 0.005`), **explicit** currency on both sides (blank ≠ blank; review LOW 1), null-tolerant contractor-EIK match (a value collision onto a different contractor is refused for free; review MEDIUM 5). `COUNT(*) OVER (PARTITION BY amendment_id)` flags uniqueness.
+4. **Group rule** (reviews todorkolev #2, nikimilenkov MEDIUM 1 & 2, revised against the live corpus): a member's OWN unique (`n_match = 1`) exact match always applies — it is individually trustworthy (the 99.99% figure) and is **not** voided when annex-number siblings point elsewhere, because an annex number can be a **lot-base** shared across contracts (live corpus: `20РП-У50А015` → …-Л01 @ 22569.98 **and** …-Л03 @ 28557.50, each annex exactly-uniquely matching its own lot — the sole disagreement group in the whole corpus, and it is correct). Members with **no** own unique match (value-less admin steps, or later steps whose cumulative value matches no `signing_value`) inherit one agreed group target — but **only** when the direct members agree; disagreement withholds *propagation*, never the direct hits. A member that is itself value-ambiguous (`n_match ≥ 2`) carries its own contradicting evidence and never links, directly or by inheritance.
+
+ > An earlier revision refused the whole group on disagreement (nikimilenkov MEDIUM 1 as first stated). Measured on the live corpus that dropped 2 confirmed-correct multi-lot links and prevented **zero** wrong links (the only disagreement group is the benign 00026 lot-base case), so the rule keeps direct hits and gates only propagation.
+5. `UPDATE raw_amendments SET contract_number = resolved, contract_number_raw = , link_method = 'value_anchor'` in place — like the #286 УНП bridge, but **preserving provenance** (review MEDIUM 4). The rollup, `promote-amendments.sql`, and the serving join then link with no further change; the original annex number stays in `contract_number_raw`, which also keeps it in the amendment `natural_key` so a resolved row never collides with a native annex sharing `document_number` on the target (review MEDIUM 3).
+
+Diagnostic printed by wrangler: `annexes_value_linked`, `eop_annexes_still_unlinked` (complementary predicates over the same mismatch population).
+
+Idempotent: after the rewrite the row links by number, so a re-run's `grp` no longer selects it and the provenance columns are never re-stamped.
+
+## 4. Slice / daily path — **implemented** (corpus-safe resolver inside `refresh-slice.sql`)
+
+Originally deferred: the standalone `resolve-amendment-contracts.sql` is **not** run on the slice path (`runSliceDerive`), because it would execute against `refresh-slice.sql`'s **windowed** `raw_contracts`, where "unique on the procedure" means "unique **in the window**" — a corpus-ambiguous annex looks unique in a narrow window and mislinks, and the measured 99.99% precision (a full-corpus number) would not carry (PR #308 review, nikimilenkov HIGH 1).
+
+The daily/slice + Worker path now runs a corpus-safe equivalent **inside `refresh-slice.sql`** (search `#306: slice-safe value-anchor resolver`), addressing PR #308 review todorkolev "дневните обновявания": the cron runs only `refresh-slice.sql`, so the fix had to live there or stay inert in production. It applies the same value/currency/EIK anchor, chain rules, and provenance stamping as the full path, differing only in the candidate source:
+
+- Candidate contracts are drawn from the **served `contracts` table** (the whole corpus; `unp` via the `tender_id` suffix, contractor ЕИК via the winning `bidders.eik_normalized`) **UNIONed** with this window's `raw_contracts`, so uniqueness is asked corpus-wide. It is intentionally **not** under a byte-identical lockstep marker — the candidate source differs by construction. Scans are bounded to procedures with an EOP annex in this window (`window_unps`), keeping the served read a keyed lookup (`idx_contracts_tender_id`).
+- Resolved targets land in `refresh_touched_contracts` **for free**: the resolver rewrites the target `contract_number` onto `raw_amendments` in the setup batch, and the existing `@refresh-batch amendments` touch join (`raw_amendments` → `contracts` on `contract_number`) then scopes prior-window targets into it.
+- It runs in the setup batch **before** the prefer-EOP dedup DELETE, the same ordering the full path uses before `derive-amendments.sql`'s dedup (review todorkolev #1 blocker — otherwise a rewrite resurrects an OCDS twin and trips `amendment-twin-dedup` #303).
+- The "is this annex's number already a real contract?" question is asked over a **value-agnostic** `all_contract_numbers` CTE (every contract number on the procedure, from window `raw_contracts` **and** served `contracts`, regardless of `signing_value`) — **not** over `contract_candidates` (which requires `signing_value > 0` for value matching). This keeps the slice path identical to the full path's `NOT EXISTS … raw_contracts`: an annex that matches a **zero-value** contract by number links by number, and is never value-linked to a neighbour (review todorkolev discrepancy).
+
+**Deployment (review todorkolev blocker).** `refresh-slice.sql` now writes `contract_number_raw` + `link_method` into served `amendments`, but the deployed DBs are populated out-of-band so `wrangler d1 migrations apply` can't run. `deploy.yml` gains an idempotent step (probe `pragma_table_info`, `ALTER` only the missing columns, malformed response fatal) **before** the Worker deploys — otherwise the first cron after release crashes on the missing columns. If #307 merges first, fold these two columns into its provenance step instead.
+
+Provenance keeps slice and full keys aligned: the amendment `natural_key` (winners dedup + promotion) uses the raw annex number via `COALESCE(NULLIF(contract_number_raw,''), contract_number, '')`, and the served `amendments` promotion carries `contract_number_raw` + `link_method`. A resolved annex re-emitted in a later slice window re-resolves deterministically to the same key — an honest, never-double-counted link. Where a target contract and its namespace-mismatched annex arrive in the **same** window, the annex still links (window `raw_contracts` is in the candidate union); the residual best-effort gap is only a target never served or staged, which the next full rebuild closes (#286 precedent).
+
+## 5. Blast radius (auto-corrects on a full rebuild)
+
+Everything is rebuilt from scratch by `precompute.sql` (DELETE/INSERT), so it self-corrects: `company_totals`, `authority_totals`, `sector_totals`, `home_totals`, `facet_counts`, `flow_pairs`, `cpv_division_stats`, `search_index`, and each contract's `annex_count`/`current_value`/timeline. Overall annex counts and sums on the annexes themselves are unchanged. Two things to verify after the run: (1) `cpv_division_stats` p95/p99 bands shift for affected divisions; (2) **merge-order dependency** — ~23 newly-linked annexes have `value_after ≥ 2× value_before` and should trip the #307 double-count flags (`annex_total_suspect`/`value_suspect`). Those flags do **not** exist on this branch (they are #307, in review); if this merges before #307, the newly-linked ≥2× annexes enter the aggregates **unflagged** until #307 lands. Track the merge order explicitly.
+
+**Migration numbering (PR #308 review todorkolev "сблъсък на номера").** #307 adds `0006_amendment_restated.sql` + `0007_amendment_value_suspect.sql`; this branch's provenance migration is therefore numbered **`0008_amendment_provenance.sql`** to sit after both. This assumes #307 merges first (it should — the ≥2× flags above depend on it). If #308 lands first instead, renumber to `0006` and have #307 shift to `0007`/`0008`.
+
+## 6. Tests
+
+`packages/db/src/amendments-contract-resolve.test.ts` runs the real `resolve-amendment-contracts.sql` + `derive-amendments.sql` (the full-derive composition) against SQLite and pins: single-contract link (00017 shape), multi-lot value disambiguation (00011 shape), chain propagation + `current_value` = last step, value-less chain member inherits (MEDIUM 2), ambiguous-in-chain member stays unlinked (todorkolev #2), whole-group refusal on disagreeing anchors (MEDIUM 1), cumulative-staging duplicate counted once (HIGH 2), EIK guard (MEDIUM 5), value-ambiguous / no-match / currency-guard / blank-vs-blank-currency (LOW 1) → unlinked, twin-ordering (the resolver runs before the prefer-EOP dedup, no `annex_count = 2` — todorkolev #1 blocker), gate (derive-alone does not resolve — HIGH 1), idempotency, natural-key collision avoidance (MEDIUM 3), provenance through promote into served `amendments` (MEDIUM 4), already-linked untouched, and the diagnostic counts.
diff --git a/docs/review-testing.md b/docs/review-testing.md
index 3ab4b106..0cbd4b0a 100644
--- a/docs/review-testing.md
+++ b/docs/review-testing.md
@@ -43,6 +43,26 @@
- Нов workspace с тестове се добавя и в `coverage-baseline.json` (и получава `vitest.config.ts`
с `sharedCoverage(...)`); `packages/api-contract` е освободен, докато няма тестове.
+### `scripts/` е извън ratchet-а — прегледано изключение, не пропуск
+
+Ratchet-ът покрива само `apps/*` и `packages/*`. `scripts/` (~7 хил. реда, сред които клеветнически
+критичната логика на свързаните лица — доказателственият ред, съвпадението по имена, гейтовете при
+публикуване) не се измерва от него. Записано е тук, за да е решение, а не дупка, която някой открива
+по-късно.
+
+**Защо:** тези файлове са `.mjs`, пускат се от три отделни runner-а извън `turbo run test` и голяма част
+от тях са I/O обвивки около мрежа и SQLite, за които процент покритие би измервал колко от обвивката е
+докосната, а не дали правилото е вярно.
+
+**Какво пази качеството вместо процент:** и трите набора са в CI и са задължителни —
+`node --test scripts/*.test.mjs`, `node --import ./scripts/cacbg/register-ts.mjs --test
+scripts/cacbg/*.test.mjs scripts/tr/*.test.mjs` — а гейтовете, при които грешка означава поименно
+твърдение за реален човек, се проверяват **мутационно**: тестът трябва да пада при обърнато условие,
+иначе не е гейт. Виж ADR-0027 за защо тест, който минава и преди реализацията, е фалшив гейт.
+
+**Кога това трябва да се преразгледа:** ако `scripts/` започне да носи логика, която не е нито
+чиста функция с мутационно проверен тест, нито тънка I/O обвивка.
+
## Integrity gate
- Пуска се върху обслужвания D1 след `precompute` в `ship-domain.mjs` и след `runSliceDerive()` в
diff --git a/docs/runbooks/related-persons-suppression.md b/docs/runbooks/related-persons-suppression.md
index ac2b140c..7d407cd3 100644
--- a/docs/runbooks/related-persons-suppression.md
+++ b/docs/runbooks/related-persons-suppression.md
@@ -4,6 +4,21 @@ A contested, corrected, or legally-challenged link on the свързани-ли
the version-controlled suppression list. See [ADR-0031](../adr/0031-suppressions-version-controlled-fingerprinted.md)
for why this is a git list keyed on an HMAC fingerprint, not a DB row.
+## Grounds that require a takedown
+
+Two of these are specific to the Trade Register evidence (#279, ADR-0033) and are easy to miss, because
+neither shows up as a bug: the pipeline is working correctly and the claim is still wrong.
+
+- **A court-annulled entry (чл. 29 ЗТРРЮЛНЦ).** A registry entry set aside by a court is void, but the
+ deed we cached may still carry it, and the rules that read it are unchanged — so a rules-version bump
+ will NOT remove the link. This is an evidence-invalidation ground, and suppression is the mechanism.
+- **A match against the wrong person (a namesake).** The register carries no ЕГН, so a three-name match
+ inside one entity is strong evidence of identity but not proof. If the named official is not the person
+ in the act, the link is a false public claim about them and must come down immediately — do not wait for
+ a rules change or a re-crawl.
+- The ordinary grounds are unchanged: a contested link under review, a correction the source has since
+ published, or a legal challenge.
+
## What you need
- The exact `link_key`. Self links are `pid|eik`; a close-relative link is `pid|eik|family`. Get it from the
@@ -46,6 +61,38 @@ wrangler d1 execute --remote \
Then do the Standard takedown steps above **in the same shift**. The direct UPDATE is undone by the next ship
(which rebuilds the table); only the git list makes it permanent.
+## Correcting a wrong INPUT (a different list — read this before reaching for a suppression)
+
+Suppression keeps a *built* link out of the public surface. It is the wrong tool when the link should never
+have been built at all — a misparsed declaration row, a stake attributed to the wrong filing, an entity
+that was never declared. There you fix the input, and the link stops existing.
+
+That removal still has to get past the monotonicity gate ([ADR-0033](../adr/0033-registry-evidence-replaces-name-distinctiveness.md)
+decision 6), which hard-fails on a link that was published last run and is not published now. And you
+cannot use a suppression to clear it: correcting the input unbuilds the link, so the fingerprint would
+match nothing and `load.mjs` fails on the unmatched-entry rail instead. The two removals fail in opposite
+directions, which is why there is a second list.
+
+1. Compute the fingerprint exactly as above (same salt, same function, same `key_version`).
+2. Append one line to `scripts/cacbg/link-corrections.jsonl`:
+
+ ```json
+ {"fp":"","key_version":"1","reason":"the declaration row was misparsed — ","signal_ref":"","corrected_at":"2026-08-11"}
+ ```
+
+3. Land it **in the same PR as the input fix**. The loader flags that key in the pre-wipe snapshot and the
+ audit reads it as a declared removal — printed, never silent, and attributed to this ground rather than
+ flattened into "removed".
+
+**An acknowledgement is one-shot. Delete the line in the next change.** Once the corrected link stops being
+published it also stops appearing in the prior set, so the entry matches nothing and the build fails on the
+same rail suppressions use. That is deliberate: a stale acknowledgement would sit in the list and silently
+pre-clear a *future* disappearance of that same link — the exact regression the gate exists to catch, with
+nobody having decided it.
+
+Do not use this list to clear a removal you do not understand. If a published link vanished and you cannot
+name why, that is the gate doing its job; find the cause.
+
## Fail-closed behaviour
`load.mjs` aborts the build (non-zero exit) on any of three conditions — each would otherwise silently
@@ -65,6 +112,30 @@ If a ship fails on any of these, fix the cause; do **not** empty the list or dro
A rotated salt invalidates every existing fingerprint, so it is a coordinated change guarded by
`key_version`: re-derive `fp` for each entry from its original `link_key` under the new salt (you need the
plaintext keys, kept out-of-band, e.g. in the incident tickets), **bump every entry's `key_version`** to the
-new value, replace the file in one commit, and update both CI secrets (`SUPPRESSION_SALT` and
-`SUPPRESSION_KEY_VERSION`). Because the loader refuses any entry on a non-current `key_version`, a
-half-finished rotation fails the build loudly instead of silently un-suppressing.
+new value, replace the file in one commit, and update **both** CI settings — but note they are different
+kinds: `SUPPRESSION_SALT` is a repository **secret**, while `SUPPRESSION_KEY_VERSION` is a repository
+**variable** (`${{ vars.SUPPRESSION_KEY_VERSION || '1' }}` in `related-persons-data.yml`). The version is
+not sensitive — it is a counter — and looking for it under Secrets is a dead end during an incident.
+Because the loader refuses any entry on a non-current `key_version`, a half-finished rotation fails the
+build loudly instead of silently un-suppressing.
+
+## Verifying a takedown actually worked
+
+A takedown that silently failed looks exactly like one that succeeded: the entry sits in the list, the
+build is green, and the link is still public. The list is applied at LOAD time, so nothing changes on the
+served surface until the next data run ships — check the surface, not the commit.
+
+1. **The loader saw it.** The run refuses an entry matching no built link (the B3 unused-entry rail), so a
+ green run already proves the fingerprint matched something. A run that fails with „suppression matched
+ NO link" means the `link_key` was wrong — a family link needs its `|family` suffix.
+2. **The row is gone from the served D1**, which is the only copy a reader can reach:
+ ```
+ wrangler d1 execute "$SIGMA_D1_NAME" --remote \
+ --command "SELECT status FROM interest_links WHERE link_key = ''"
+ ```
+ Expect `suppressed`, or no row at all. Anything else means the ship did not carry the decision.
+3. **The page is gone**, allowing for cache: the link's page must 404 and the official's page must not
+ list it. `publicCache(3600)` means a reader can still see it for up to an hour after the write — if it
+ is still there beyond that, the takedown did not land.
+4. **It stays gone.** Re-run the next scheduled load and repeat step 2: the list is what makes a takedown
+ survive a rebuild, and this is the only step that proves the survival rather than assuming it.
diff --git a/docs/spec/related-persons-foundation.md b/docs/spec/related-persons-foundation.md
index 8c50fa94..61d399f5 100644
--- a/docs/spec/related-persons-foundation.md
+++ b/docs/spec/related-persons-foundation.md
@@ -70,6 +70,13 @@ Of #128's five checkboxes: **1** (shared owners) contingently unblocked via §3.
**Per-ЕИК public lookup** (`portal.registryagency.bg`) is free, no fee. Recoverable per ЕИК: **управители, съдружници/собственици на капитала + stakes, капитал, legal form, seat.** **ЕГН masked** → owner identity name-based. Run **only** for the bounded set (bidders + resolved declared/donor companies) — thousands, not ~900k.
**Hard constraints (DPA-verified):** **NEVER bulk-scrape TR** (КЗЛД ruled bulk provision unlawful; CJEU C-200/23 climate). **Never store scraped ЕГН**, even if leaked in a document image. Phase-0 confirms endpoint, fields, and rate limits.
+**Phase-0 is CLOSED — measured 2026-08-04/05 (#279, ADR-0033).** Endpoint: `GET portal.registryagency.bg/CR/api/Deeds/{ЕИК}`, public JSON, no authentication. Facts worth recording because three of them contradict what the issue predicted:
+- **The register rate-limits, and the block is sustained.** HTTP 429 at ~50 cumulative requests ending in a burst, then 429 to everything — no `Retry-After`, no quota header. So a 429 is an instruction to stop, never a transient to retry through: the crawler ends the run, marks nothing, and resumes later. The implemented pace is 1 request / 3 s, sequential, over a **closed** candidate set the crawler cannot extend (it never follows a link out of a deed).
+- **„Not in the register" is an HTTP 200 with a ZERO-BYTE body**, not a 404 and not HTML. Verified on Община София (`000696327`): empty twice while a real company returned its full deed in the same window. Permanence therefore keys on the **status**, not the empty body — empty under 200 is an answer and may be cached; empty under 5xx is a failure and stays transient.
+- **`fetch` does not work against this host.** The identical request returns 500/empty via undici and 200 with the full deed via `node:https`.
+- Field values are HTML fragments inside the JSON envelope, and one field routinely holds several people separated by ``; erasure is marked structurally (`erasure-text-inline`), not textually. Matching must happen **inside one entity**, or one person's given name combines with another's surname.
+- **ЕГН is absent** from every payload examined, and is never stored either way. Raw deeds live only under git-ignored `scratch/tr/` with a 35-day retention; the cache index holds no name at all.
+
## 4. Data model
New domain tables, built by `normalize-raw.sql` from persistent staging; `interest_links` written by a **JS resolver pass** (§5), NOT by fuzzy SQL.
@@ -182,6 +189,7 @@ The public site MUST carry a plain-language methodology page. It is part of the
1. **Sources + legal basis** — CACBG declarations (ЗСП чл. 75, Сметна палата); ЕРИК (Изборен кодекс); targeted TR per-ЕИК (public company facts). Each with a direct source link.
2. **What is shown and what is not** — officials' own declared holdings; **family holdings are NOT published** (v1); addresses/ЕГН never shown.
3. **The matching rule, verbatim** — declared company **full name** (incl. legal form) is normalized and matched **exactly** against the winner's registered name, which is paired 1:1 with its ЕИК in the procurement source; because Bulgarian trade names are **nationally unique** (ЗТРРЮЛНЦ/ТЗ), an exact match is the same legal entity. Ambiguous cases (truncated names, no exact match) are **excluded or human-confirmed, never guessed**. Every link shows its full evidence chain.
+ **Superseded in part by #279 / ADR-0033:** a name match is no longer *sufficient* to publish. Every link additionally rests on a checkable **Търговски регистър** fact, and the methodology page carries the six-rung evidence ladder verbatim, together with the disclosures that make it honest: the register carries **no ЕГН**, so a three-name match is not proof of identity and a namesake is possible; the match must therefore be a **full three-token subset inside a single registry entity**; seats move, so a registered seat only confirms a period it predates; erased entries are skipped; and every published link shows its **entry number and lookup date**.
4. **Certainty & framing** — only deterministic exact matches (and human-confirmed cases) appear; each is a „**модел за проверка, не обвинение**"; the tool shows *declared* links only and **does not claim to find hidden ownership** (it is a floor, not the full picture).
5. **Temporal meaning** — a declaration is a point-in-time snapshot; how overlap with a contract date is (and isn't) interpreted.
6. **Known gaps** — the recall holes (§5), self-reporting limits, sources it cannot see.
diff --git a/docs/spec/related-persons-lia.md b/docs/spec/related-persons-lia.md
index 1309c42a..07523940 100644
--- a/docs/spec/related-persons-lia.md
+++ b/docs/spec/related-persons-lia.md
@@ -128,6 +128,40 @@ v Poland* (2428/05) приема онлайн публикуване на дек
регистър. Приемаме този оспорим елемент съзнателно. **Резервен план при оспорване:** един филтър (сваляне на
семейните връзки към еднолични дружества), не спиране на повърхността.
+## 9а. Допълнение: справки в Търговския регистър (#279, ADR-0033)
+
+Нов **източник** и нова **обработка**, затова оценката се допълва, а не само се препотвърждава.
+
+**Каква е обработката.** За всяко дружество-кандидат правим една справка в публичния Търговски регистър и
+четем живия акт. Актът съдържа **лични данни на трети лица** — имена на съдружници, собственици и
+управители, които не заемат публична длъжност, както и адрес на дружеството.
+
+**Цел и основание.** Целта е тясна и проверима: да се установи **самоличността на дружеството** зад
+декларирано име, преди срещу конкретно лице да бъде публикувано твърдение. Това е мярка, която *намалява*
+риска за субектите на данни — досега самоличността се решаваше по евристика за отличителност на името, а
+сега — по документ. Основанието е същият надделяващ обществен интерес по ЗДОИ чл. 41и, ал. 4, приложен към
+по-точна, а не към по-широка публикация.
+
+**Минимизиране — какво НЕ правим.**
+- Имената от акта се ползват **само в булеви сравнения** и не влизат в никаква публична таблица, отговор или
+ лог. Индексът на кеша не съдържа нито едно име — само ЕИК, дати, кодове и хеш на тялото.
+- Адресът **не се извлича**: от полето за седалище се чете единствено сегментът „Населено място".
+- Полетата за **действителен собственик** (`CR_F_550_L`) и за контрол (`537/538`) **не се ползват** — това
+ би било претенция за действителна собственост, оставена настрана след CJEU C-37/20 (ADR-0007, т. 1).
+- Суровите актове се пазят само служебно, в git-игнорирана директория, със **срок 35 дни**.
+- Обхватът е **затворен**: справка се прави само за ЕИК, които вече са резултат от съпоставянето; обхождащият
+ никога не следва връзка навън от акт. Това не е масово изтегляне (спец. §3.3).
+
+**Остатъчен риск — съименник.** Регистърът **не съдържа ЕГН**, затова съвпадението по трите имена е силно, но
+не абсолютно доказателство за самоличност. Смекчаване: изисква се пълно съвпадение и на трите имена в **един и
+същ запис** на акта; имена с инициали или с латински букви не се приемат; фамилни съвпадения не се ползват
+никъде. Грешката тук не измисля твърдение за собственост — тя **закача длъжностното лице за друго дружество**,
+което е също толкова невярно и се сваля по реда на ADR-0031 (виж runbook-а: „съвпадение с грешно лице").
+
+**Отложено съзнателно.** Етикетите „и към днешна дата" по т. 7 на #279 **не се пускат** в тази фаза: те
+твърдят сегашно време за поименно лице, а давността им е ограничена от цикъла на опресняване. Влизат само
+след отделен преглед на настоящата оценка.
+
## 10. Статус и преглед
Draft за правен преглед; изпраща се паралелно с пускането, не като блокер (ADR-0032, т. 7). Ревизира се при промяна
diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts
index d1d9f709..75f100cb 100644
--- a/packages/api-contract/src/index.ts
+++ b/packages/api-contract/src/index.ts
@@ -252,6 +252,9 @@ export interface ContractValueTimeline {
currentEur: number | null;
deltaPct: number | null; // (current − signing) / signing, when both present
suspect: boolean; // value_/annex_suspect/review → render with an unverified-value label
+ // annex_total_suspect → the current value is a KNOWN exact 2× double-count. currentEur is blanked (—)
+ // rather than shown as a labelled doubled figure — a known-wrong number is worse than an honest gap (#307).
+ currentValueDoubled: boolean;
}
export interface ContractLotRow {
@@ -282,6 +285,8 @@ export interface AmendmentEntry {
description: string | null; // recorded reason/notes, when the source carries them
valueAfterEur: number | null; // the contract value after this annex
deltaEur: number | null; // value_after − value_before
+ restated: boolean; // #305 Tier-2: value_after was text-corrected from a double-counted total
+ suspect: boolean; // #305 residual: an uncorrectable double-count — value_after/delta suppressed, row marked
}
export interface ContractDetail {
@@ -705,6 +710,15 @@ export interface ConflictLink {
firstContractYear: string | null;
lastContractYear: string | null;
sourceUrl: string | null; // a representative declaration URL — provenance, never a fabricated value
+ // Trade Register evidence (#279, ADR-0033). A link only reaches this DTO when its identity rests on a
+ // checkable registry fact, so these describe WHICH fact — the surface's whole point is that every shown
+ // link can explain itself. `registryRole` is the role the register records, NOT a claim about who owns
+ // what: the ownership claim comes from the official's own declaration.
+ evidenceKind: 'document' | 'confirmed'; // the only two rungs that publish
+ registryRole: 'owner' | 'manager' | null; // set only for evidenceKind='document'
+ registryEntryNumber: string | null; // TEXT — a fieldEntryNumber exceeds the exact-integer range
+ registryEntryDate: string | null; // the registry entry the evidence rests on
+ registryLookupDate: string; // when the deed was read — the freshness bound on the claim
}
/** One contract of a linked winner, marked by whether it was signed during the declared-stake window.
diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql
index 90f98dac..57002fd3 100644
--- a/packages/db/migrations/0000_init.sql
+++ b/packages/db/migrations/0000_init.sql
@@ -125,13 +125,13 @@ CREATE TABLE contracts (
bids_received INTEGER,
contract_kind TEXT, -- Доставки / Услуги / Строителство
awarded_to_group INTEGER, -- this AWARD went to an обединение (per-contract, distinct from bidders.is_consortium)
- value_flag TEXT NOT NULL DEFAULT 'ok', -- ok | review | value_low | value_suspect | annex_suspect (data-quality verdict; assigned in scripts/normalize-raw.sql)
+ value_flag TEXT NOT NULL DEFAULT 'ok', -- ok | review | value_low | value_suspect | annex_suspect | annex_total_suspect (data-quality verdict; assigned in scripts/normalize-raw.sql)
date_flag TEXT NOT NULL DEFAULT 'ok', -- ok | signed_after_publication (non-destructive date-quality verdict)
amount_eur REAL, -- canonical EUR, SAFE TO SUM; populated for all flags (value_suspect repaired to the procedure estimate); NULL only when no trustworthy EUR figure (FX-rateless foreign / value_suspect w/o estimate / no signing+current)
fx_converted INTEGER NOT NULL DEFAULT 0, -- 1 = amount_eur came from a foreign-currency market rate
fx_rate REAL, -- EUR per 1 unit of `currency` for foreign rows (amount × fx_rate = amount_eur)
signing_value_eur REAL, -- signing_value in EUR (peg/fx); NULL for value_suspect — for the contract value timeline
- current_value_eur REAL, -- current_value in EUR; NULL for value_suspect/annex_suspect (suspect annex suppressed)
+ current_value_eur REAL, -- current_value in EUR; NULL for value_suspect/annex_suspect/annex_total_suspect (suspect annex suppressed)
lot_id TEXT, -- domain lot id ('lot:'||УНП||':'||raw) when the award is lot-scoped; soft-links lots(id)
document_number TEXT, -- Номер на документ
published_at TEXT, -- Публикуван на
diff --git a/packages/db/migrations/0006_amendment_restated.sql b/packages/db/migrations/0006_amendment_restated.sql
new file mode 100644
index 00000000..5f158067
--- /dev/null
+++ b/packages/db/migrations/0006_amendment_restated.sql
@@ -0,0 +1,17 @@
+-- #305 Tier-2 text-based value correction (packages/ingest/src/amendment-total.ts). Some ЦАИС ЕОП
+-- annexes put the announced NEW TOTAL into the change field, doubling value_after. The основание text
+-- resolves each: a restated total drives the corrected value_after (and current_value), a genuine
+-- increment is confirmed correct. The served amendments row records the outcome so the UI can mark a
+-- corrected row and the refresh-slice reconciliation can skip text-treated annexes when arithmetic-flagging.
+--
+-- value_restated = 1 when value_after was rewritten to the text-confirmed true total, else 0.
+-- value_treatment = the raw treatment label ('total_restated' / 'unchanged_restated' /
+-- 'genuine_increment', NULL when the text carried no signal). Kept alongside
+-- value_restated because the slice reconciliation re-classifies from the served
+-- amendments and must skip confirmed-genuine increments (value_restated stays 0 there).
+-- Additive columns. On the live stage DB (whose migration ledger is empty — base schema imported
+-- out-of-band) these are applied by the column probe in .github/workflows/deploy.yml, which ALTERs only
+-- when the column is missing; on a fresh ledger `d1 migrations apply` runs this file exactly once. SQLite
+-- has no `ADD COLUMN IF NOT EXISTS`, so do not replay this file against a DB that already has the columns.
+ALTER TABLE amendments ADD COLUMN value_restated INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE amendments ADD COLUMN value_treatment TEXT;
diff --git a/packages/db/migrations/0007_amendment_value_suspect.sql b/packages/db/migrations/0007_amendment_value_suspect.sql
new file mode 100644
index 00000000..faa1b195
--- /dev/null
+++ b/packages/db/migrations/0007_amendment_value_suspect.sql
@@ -0,0 +1,14 @@
+-- #305 residual: a contract flagged value_flag = 'annex_total_suspect' has its current_value excluded
+-- from every aggregate, but the served amendments row still carried the DOUBLED value_after — so the
+-- amendment timeline kept showing the untrusted figure. Only text-corrected rows (value_restated) had
+-- their served value rewritten; the ~183 flag-only doubles (non-exact >2×, no основание signal) did not.
+-- We do NOT know their true total, so we MARK the row and let the UI SUPPRESS the untrusted figure —
+-- never invent a number.
+--
+-- value_suspect = 1 when the served amendment is a suspected double-count NOT already text-treated
+-- (value_treatment IS NULL), else 0. A restated/genuine row (value_treatment set) is
+-- never also suspect. The UI blanks value_after/delta for a suspect row.
+-- Additive column. Applied on the live stage DB by the column probe in .github/workflows/deploy.yml (ALTER
+-- only when missing); on a fresh ledger `d1 migrations apply` runs it once. SQLite has no `ADD COLUMN IF
+-- NOT EXISTS`, so do not replay this file against a DB that already has the column.
+ALTER TABLE amendments ADD COLUMN value_suspect INTEGER NOT NULL DEFAULT 0;
diff --git a/packages/db/migrations/0008_amendment_provenance.sql b/packages/db/migrations/0008_amendment_provenance.sql
new file mode 100644
index 00000000..fc8d4727
--- /dev/null
+++ b/packages/db/migrations/0008_amendment_provenance.sql
@@ -0,0 +1,9 @@
+-- #306: provenance for value-anchor-linked annexes. The resolver in
+-- scripts/resolve-amendment-contracts.sql rewrites a namespace-mismatched annex's contract_number to its
+-- target contract; unlike the #286 OCDS bridge (whose OCID survives in tender_ext_id + source), the annex
+-- number would otherwise be destroyed with no trace, making the 99.99%-precision claim unauditable in
+-- production and any "this annex isn't ours" complaint uninvestigable (review nikimilenkov MEDIUM 4).
+-- Keep the original annex-side number and stamp the link method so value-linked rows stay enumerable on the
+-- served side. NULL on both columns = the row linked by contract_number directly (or is unlinked).
+ALTER TABLE amendments ADD COLUMN contract_number_raw TEXT;
+ALTER TABLE amendments ADD COLUMN link_method TEXT;
diff --git a/packages/db/migrations/0009_interest_link_evidence.sql b/packages/db/migrations/0009_interest_link_evidence.sql
new file mode 100644
index 00000000..e91869d1
--- /dev/null
+++ b/packages/db/migrations/0009_interest_link_evidence.sql
@@ -0,0 +1,50 @@
+-- Trade Register evidence seal for a свързани-лица link (#279 §8, ADR-0033).
+--
+-- WHY A SIDE TABLE and not columns on interest_links, since both were on the table:
+-- 1. SQLite's ALTER TABLE ... ADD COLUMN has no IF NOT EXISTS, and migrations here are applied by a
+-- bare `wrangler d1 execute --file` with no applied-migrations tracking (see the workflow steps),
+-- so a re-apply MUST be a no-op. `CREATE TABLE IF NOT EXISTS` is; ADD COLUMN is not.
+-- 2. scripts/cacbg/load.mjs rebuilds the CACBG tables from 0003 ALONE. A column added here would have
+-- to be duplicated into 0003 and kept in step with it forever, and the day the two diverge the
+-- loader silently drops the evidence for every link it writes.
+-- 3. §8 describes the seal as an attached artefact of a link, which is what this is.
+--
+-- 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 — without them „why is this one hidden?" has no answer.
+--
+-- PII rail: `matched_fact` is a CLOSED VOCABULARY — 'seat:' | 'role:owner:' |
+-- 'role:manager:' | 'eik'. It must NEVER carry the matched name. The registry deed's names are
+-- read only to produce a boolean and never leave git-ignored scratch (ADR-0033 decision 5); storing one
+-- here would put third-party personal data on the served surface, which #279 §9 forbids outright. The
+-- audit enforces the vocabulary with a pattern check, because a schema cannot.
+
+CREATE TABLE IF NOT EXISTS interest_link_evidence (
+ link_key TEXT PRIMARY KEY REFERENCES interest_links(link_key),
+ -- ADR-0033 decision 1, plus document_uncorroborated (ADR-0035: a name match whose COMPANY nothing
+ -- corroborated). CHECKed because the read gate filters on this column — SURFACED_OWNERSHIP admits
+ -- exactly 'document' and 'confirmed' — so an unlisted value is either a link that silently stops
+ -- surfacing or, if it collides with a publishing name, one that surfaces unproven.
+ evidence_kind TEXT NOT NULL
+ CHECK (evidence_kind IN ('document','confirmed','document_uncorroborated','refuted',
+ 'bar_joint_stock','unknown','outside_tr')),
+ -- owner | manager | NULL — only meaningful for evidence_kind='document'. The card renders this as
+ -- „вписан като …", so a stray value becomes a public claim about a named person's registry role.
+ registry_role TEXT CHECK (registry_role IS NULL OR registry_role IN ('owner','manager')),
+ matched_fact TEXT, -- the closed vocabulary above. NEVER a name.
+ -- TEXT, not INTEGER: a fieldEntryNumber like 20130716101007 is already 14 digits and exceeds the
+ -- exact-integer range once it round-trips through JSON/JS.
+ entry_number TEXT,
+ entry_date TEXT, -- ISO date of the registry entry the evidence rests on
+ lookup_date TEXT NOT NULL, -- when the deed was fetched — the freshness bound on the claim
+ rules_version TEXT NOT NULL, -- evidence.mjs RULES_VERSION; §8's monotonicity gate keys on this
+ -- live | terminated_owner_still | terminated_manager_still | terminated.
+ -- RE-DERIVED on every run and deliberately NOT part of the seal's permanence: it asserts a present
+ -- tense about a named person, and its freshness is bounded by the cache refresh cycle (ADR-0033 R3).
+ live_status TEXT NOT NULL
+ CHECK (live_status IN ('live','terminated_owner_still','terminated_manager_still',
+ 'terminated')),
+ sealed_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- The surface filters on evidence_kind ('document' | 'confirmed' publish); the audit scans by kind too.
+CREATE INDEX IF NOT EXISTS idx_ile_kind ON interest_link_evidence(evidence_kind);
diff --git a/packages/db/migrations/0010_publishing_gate_constraints.sql b/packages/db/migrations/0010_publishing_gate_constraints.sql
new file mode 100644
index 00000000..b3e9d4cf
--- /dev/null
+++ b/packages/db/migrations/0010_publishing_gate_constraints.sql
@@ -0,0 +1,123 @@
+-- The publishing-gate constraints, for EVERY database — freshly built and already-deployed alike (#279 §2).
+--
+-- WHY THIS MIGRATION OWNS THEM RATHER THAN 0003 (todorkolev, #309). The obvious move is to add the CHECKs
+-- to `interest_links` in 0003, where the table is declared. That does not work and is worse than not
+-- trying: 0003 is ALREADY APPLIED everywhere, `CREATE TABLE IF NOT EXISTS` is a no-op against an existing
+-- table, and `ship-related-persons.mjs` wipes ROWS (`DELETE FROM`, WIPE_ORDER), never definitions. So an
+-- in-place CHECK would exist only on databases built after the edit — putting two different schemas under
+-- one name, with the constraint absent precisely on the served database. That is where a hand-run
+-- `UPDATE … SET status='published '` during an incident actually lands.
+--
+-- So 0003 stays byte-identical to what was applied, and enforcement lives here, reached by both paths:
+-- a fresh chain runs 0007 after 0003, and a deployed database gets it as a retrofit. One shape, one
+-- mechanism. `packages/db/src/migrations.test.ts` holds the two shapes to the same rejections and the same
+-- acceptances, so this stays true rather than merely intended.
+--
+-- WHY TRIGGERS AND NOT A TABLE REBUILD: SQLite cannot add a CHECK in place, so the textbook route is
+-- create-copy-drop-rename. That route is WRONG here, and a real `wrangler d1 migrations apply` proved it:
+-- `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 rebuild aborts with SQLITE_CONSTRAINT_FOREIGNKEY and the Durable Object rolls back. Dropping the
+-- child first would work mechanically but would strip every evidence seal, and since the read gate
+-- REQUIRES a seal that empties the public surface until the next monthly data run.
+--
+-- A BEFORE INSERT/UPDATE trigger that RAISEs enforces the identical invariant for every writer — including
+-- the hand-run UPDATE above, which no CHECK on a legacy table would ever have covered — with no rebuild,
+-- no FK exposure and no seal loss. 0006 keeps its own CHECKs: it is NEW in this change, has never been
+-- applied to a served environment, so declaring them there edits no applied history.
+--
+-- WHY IT IS SAFE TO RE-RUN: migrations here are applied by a bare `wrangler d1 execute --file` with no
+-- applied-migrations tracking (see related-persons-data.yml / deploy.yml), so re-application MUST be a
+-- no-op. Everything below is `IF NOT EXISTS` over statements that converge.
+
+-- ── declarations ────────────────────────────────────────────────────────────────────────────────────
+-- The table-level `UNIQUE (xml_file, control_hash)` did not constrain what it was written for. SQLite
+-- counts NULLs as DISTINCT and `control_hash` is genuinely optional at the source (the register omits
+-- on some declarations), so every hashless declaration was mutually unique and re-imported
+-- as a NEW row on each run, double-counting the stakes it carries. `xml_file` is also not unique across
+-- folders — the register reuses basenames per year — which load.mjs already handles by namespacing the
+-- declaration id with the folder.
+--
+-- Replaced by an expression index over COALESCE(control_hash, '') plus folder_year: NULL-proof, and the
+-- same natural key `id` already encodes. The column stays NULLABLE on purpose — NOT NULL would convert an
+-- optional source field into a run-stopping loader failure, and a fabricated placeholder hash would
+-- assert an integrity check nobody performed.
+--
+-- No table rebuild is needed for this one: the table-level `UNIQUE (xml_file, control_hash)` declared in
+-- 0003 STAYS (0003 is not edited — see the header), and the correct index is simply added alongside it.
+-- The stale constraint is strictly WEAKER than the index — it treats NULLs as distinct, so it accepts a
+-- superset — and therefore rejects nothing the index accepts. The index governs.
+--
+-- Duplicates ALREADY stored under the old non-constraint would block the index, so they are collapsed
+-- first, keeping the earliest row per natural key.
+--
+-- The DELETE below changes live data, so it ANNOUNCES itself first (cefothe, #309): the count is emitted
+-- before the rows go, and a run that removes nothing says so. Without it the only evidence a deployment
+-- silently dropped declarations would be a row count nobody recorded beforehand. It needs no explicit
+-- transaction — `wrangler d1 execute --file` runs the file as one implicit transaction, so a failure
+-- anywhere below rolls the DELETE back with it rather than leaving a half-collapsed table.
+-- The subtraction is parenthesised deliberately: `||` binds TIGHTER than `-` in SQLite, so without it
+-- this reads as ('…' || countA) - (countB || '…') — two strings coerced to numbers — and reports a
+-- meaningless figure instead of the row count. It did, before a real apply showed „notice: -2".
+SELECT 'migration 0007: collapsing ' ||
+ ((SELECT COUNT(*) FROM declarations) -
+ (SELECT COUNT(*) FROM (SELECT 1 FROM declarations
+ GROUP BY xml_file, folder_year, COALESCE(control_hash, '')))) ||
+ ' duplicate declaration row(s) before the natural-key index' AS notice;
+DELETE FROM declarations WHERE id NOT IN (
+ SELECT MIN(id) FROM declarations GROUP BY xml_file, folder_year, COALESCE(control_hash, '')
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_declarations_natural_key
+ ON declarations(xml_file, folder_year, COALESCE(control_hash, ''));
+
+-- ── interest_links: status / interest_class ─────────────────────────────────────────────────────────
+-- These two ARE the publishing gate: the surface is `status = 'published'` AND a surfaced
+-- `interest_class`. A value that merely resembles one — 'published ' with a trailing space, the case
+-- #279 §2 names — passes every writer and then fails the gate silently, hiding a link that should show.
+--
+-- One trigger per operation, since SQLite triggers are per-event. `RAISE(ABORT)` rolls back the
+-- statement, so a bad write fails loudly at its source instead of surfacing later as a missing link.
+DROP TRIGGER IF EXISTS trg_interest_links_status_ins;
+CREATE TRIGGER trg_interest_links_status_ins
+BEFORE INSERT ON interest_links
+WHEN NEW.status NOT IN ('published','internal','held','withdrawn','suppressed')
+ OR NEW.interest_class NOT IN ('private_ownership','family_ownership','ex_officio_board','management_role')
+BEGIN
+ SELECT RAISE(ABORT, 'CHECK failed: interest_links.status/interest_class outside the publishing-gate enum');
+END;
+
+DROP TRIGGER IF EXISTS trg_interest_links_status_upd;
+CREATE TRIGGER trg_interest_links_status_upd
+BEFORE UPDATE ON interest_links
+WHEN NEW.status NOT IN ('published','internal','held','withdrawn','suppressed')
+ OR NEW.interest_class NOT IN ('private_ownership','family_ownership','ex_officio_board','management_role')
+BEGIN
+ SELECT RAISE(ABORT, 'CHECK failed: interest_links.status/interest_class outside the publishing-gate enum');
+END;
+
+-- ── interest_link_evidence: evidence_kind ───────────────────────────────────────────────────────────
+-- The read gate filters on this column — SURFACED_OWNERSHIP admits exactly 'document' and 'confirmed' —
+-- so an unlisted value either silently stops a link surfacing or, if it collides with a publishing name,
+-- surfaces one that was never proven. 0006 declares this as a CHECK for new databases; the trigger is
+-- the same rule for those provisioned before it.
+DROP TRIGGER IF EXISTS trg_ile_kind_ins;
+CREATE TRIGGER trg_ile_kind_ins
+BEFORE INSERT ON interest_link_evidence
+WHEN NEW.evidence_kind NOT IN ('document','confirmed','document_uncorroborated','refuted',
+ 'bar_joint_stock','unknown','outside_tr')
+ OR (NEW.registry_role IS NOT NULL AND NEW.registry_role NOT IN ('owner','manager'))
+ OR NEW.live_status NOT IN ('live','terminated_owner_still','terminated_manager_still','terminated')
+BEGIN
+ SELECT RAISE(ABORT, 'CHECK failed: interest_link_evidence enum outside the ADR-0033/0035 vocabulary');
+END;
+
+DROP TRIGGER IF EXISTS trg_ile_kind_upd;
+CREATE TRIGGER trg_ile_kind_upd
+BEFORE UPDATE ON interest_link_evidence
+WHEN NEW.evidence_kind NOT IN ('document','confirmed','document_uncorroborated','refuted',
+ 'bar_joint_stock','unknown','outside_tr')
+ OR (NEW.registry_role IS NOT NULL AND NEW.registry_role NOT IN ('owner','manager'))
+ OR NEW.live_status NOT IN ('live','terminated_owner_still','terminated_manager_still','terminated')
+BEGIN
+ SELECT RAISE(ABORT, 'CHECK failed: interest_link_evidence enum outside the ADR-0033/0035 vocabulary');
+END;
diff --git a/packages/db/src/amendments-bridge-lockstep.test.ts b/packages/db/src/amendments-bridge-lockstep.test.ts
new file mode 100644
index 00000000..2f99e840
--- /dev/null
+++ b/packages/db/src/amendments-bridge-lockstep.test.ts
@@ -0,0 +1,42 @@
+// Issue #286 — the tender.id → УНП bridge UPDATE is duplicated in the full path (derive-amendments.sql)
+// and the incremental path (refresh-slice.sql). Both must recover the SAME УНП, or a daily slice refresh
+// on the production Worker would silently regress to the #286 bug while every functional test stayed green
+// (the Worker runs refresh-slice.sql, not derive-amendments.sql). The prefer-EOP dedup DELETE deliberately
+// diverges — the slice path additionally reconciles against the cumulative served `amendments` — so only
+// the marked bridge block is held byte-identical here. This is the repo's established drift-guard form
+// (see search-sql.test.ts / precompute-cohort.test.ts).
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+
+const START = '-- @bridge-lockstep start';
+const END = '-- @bridge-lockstep end';
+
+function bridgeBlock(file: string): string {
+ const sql = readFileSync(resolve(root, file), 'utf8');
+ const start = sql.indexOf(START);
+ const end = sql.indexOf(END, start);
+ expect(start, `no "${START}" marker in ${file}`).toBeGreaterThanOrEqual(0);
+ expect(end, `no "${END}" marker in ${file}`).toBeGreaterThan(start);
+ return sql.slice(start + START.length, end).trim();
+}
+
+describe('OCDS amendment bridge lockstep (issue #286)', () => {
+ it('the bridge UPDATE is byte-identical between derive-amendments.sql and refresh-slice.sql', () => {
+ const derive = bridgeBlock('scripts/derive-amendments.sql');
+ const slice = bridgeBlock('scripts/refresh-slice.sql');
+
+ // Sanity: the extracted span really is the bridge UPDATE, not an empty/misplaced marker range.
+ expect(derive).toContain('UPDATE raw_amendments');
+ expect(derive).toContain('raw_amendments.tender_ext_id');
+ expect(derive).toContain('ORDER BY rt.unp LIMIT 1');
+ expect(derive).toContain('ORDER BY rc.unp LIMIT 1');
+
+ // The guard: exact byte-equality. Deleting the block from refresh-slice.sql fails on the missing
+ // marker above; changing the recovery in either file fails here.
+ expect(slice).toBe(derive);
+ });
+});
diff --git a/packages/db/src/amendments-contract-resolve.test.ts b/packages/db/src/amendments-contract-resolve.test.ts
new file mode 100644
index 00000000..f0b57d13
--- /dev/null
+++ b/packages/db/src/amendments-contract-resolve.test.ts
@@ -0,0 +1,634 @@
+// Issue #306 — EOP annexes whose annex-side number is in a different namespace than the contract number
+// (the annex carries an internal number like 148846; the contract carries the buyer's filing number like
+// Д-226), so the (unp, contract_number) join drops them out of every annex→contract rollup. The resolver in
+// scripts/resolve-amendment-contracts.sql links them by the exact, currency- and contractor-matched
+// value_before → signing_value anchor (measured 99.99% precision on the real corpus), uniquely, with chain
+// propagation, leaving ambiguous / no-match annexes honestly unlinked.
+//
+// The resolver is a SEPARATE script that runs BEFORE derive-amendments.sql on the full-derive path only
+// (review todorkolev #1 blocker: running after the prefer-EOP dedup resurrects OCDS twins; review
+// nikimilenkov HIGH 1: the slice path's windowed raw_contracts can't answer "unique on the procedure").
+// These tests run the REAL scripts against SQLite via the sqlite3 CLI, exactly as runFullDerive composes
+// them — resolver, then derive — so the value anchor, dedup, group rule, guards, and provenance are
+// exercised as shipped.
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const initSchema = resolve(root, 'packages/db/migrations/0000_init.sql');
+// #305: promote-amendments.sql writes value_restated/value_treatment/value_suspect into served amendments.
+const restatedMigration = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const valueSuspectMigration = resolve(
+ root,
+ 'packages/db/migrations/0007_amendment_value_suspect.sql',
+);
+const provenanceMigration = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+const workStagingSchema = resolve(root, 'scripts/work-staging-schema.sql');
+const resolveAmendments = resolve(root, 'scripts/resolve-amendment-contracts.sql');
+const deriveAmendments = resolve(root, 'scripts/derive-amendments.sql');
+const promoteAmendments = resolve(root, 'scripts/promote-amendments.sql');
+
+function sqlite(dbPath: string, sql: string): void {
+ execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', [dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function readScriptCapture(dbPath: string, path: string): string {
+ return execFileSync('sqlite3', [dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ encoding: 'utf8',
+ });
+}
+
+// The full-derive composition: value resolver first (review todorkolev #1), then the derive rollup.
+function runFullDerive(dbPath: string): void {
+ readScript(dbPath, resolveAmendments);
+ readScript(dbPath, deriveAmendments);
+}
+
+// The numeric rows the resolver prints; the #306 diagnostic is its only 2-column row
+// (annexes_value_linked | eop_annexes_still_unlinked).
+function diagRows(out: string): number[][] {
+ return out
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => /^\d+(\|\d+)*$/.test(line))
+ .map((line) => line.split('|').map(Number));
+}
+
+let dir: string;
+let db: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(resolve(tmpdir(), 'amendments-resolve-'));
+ db = resolve(dir, 'work.sqlite');
+ readScript(db, initSchema);
+ readScript(db, restatedMigration);
+ readScript(db, valueSuspectMigration);
+ readScript(db, provenanceMigration);
+ readScript(db, workStagingSchema);
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('#306 amendment→contract value-anchor resolver', () => {
+ it('links a single-contract procedure whose annex number differs (00017 shape) by exact value', () => {
+ // UNP with ONE contract "ОП-3-016/…" signing 5800; annex carries internal number 11725, value_before
+ // 5800 (exact). The number join fails; the value anchor links it.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00017-2020-0041','ОП-3-016/03.02.2021г.',5800,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, value_delta, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00017-2020-0041','11725','2026-03-05','A1',5800,5800,0,'BGN');`,
+ );
+ runFullDerive(db);
+
+ // The annex's contract_number is rewritten to the real filing number → it now links.
+ expect(
+ sqliteJson<{ contract_number: string; contract_number_raw: string; link_method: string }>(
+ db,
+ "SELECT contract_number, contract_number_raw, link_method FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([
+ {
+ contract_number: 'ОП-3-016/03.02.2021г.',
+ contract_number_raw: '11725',
+ link_method: 'value_anchor',
+ },
+ ]);
+ // The contract picks up the annex in its rollup.
+ expect(
+ sqliteJson<{ annex_count: number }>(
+ db,
+ "SELECT annex_count FROM raw_contracts WHERE contract_number='ОП-3-016/03.02.2021г.'",
+ ),
+ ).toEqual([{ annex_count: 1 }]);
+ });
+
+ it('disambiguates a multi-contract (multi-lot) procedure by value (00011 shape)', () => {
+ // Two contracts on one procedure: 387-2020 (signing 64000) and 388-2020 (signing 56000). An unlinked
+ // annex carries internal number 2886, value_before 56000 → must link to 388-2020, NEVER 387-2020.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00011-2020-0002','387-2020',64000,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00011-2020-0002','388-2020',56000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, value_delta, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00011-2020-0002','2886','2026-03-05','A1',56000,55552.5,NULL,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '388-2020' }]);
+ expect(
+ sqliteJson<{ contract_number: string; annex_count: number }>(
+ db,
+ "SELECT contract_number, annex_count FROM raw_contracts WHERE unp='00011-2020-0002' ORDER BY contract_number",
+ ),
+ ).toEqual([
+ { contract_number: '387-2020', annex_count: 0 },
+ { contract_number: '388-2020', annex_count: 1 },
+ ]);
+ });
+
+ it('propagates the resolved target across a chain sharing the annex number, so current_value is the last step', () => {
+ // Three annexes share internal number 2886; only the FIRST carries value_before = signing (56000), the
+ // later two carry the running cumulative. All must attach to 388-2020, and current_value must be the
+ // LAST step's value_after (53580.21), not the first.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00011-2020-0002','387-2020',64000,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00011-2020-0002','388-2020',56000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00011-2020-0002','2886','2026-03-05','A1',56000,55552.5,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-06','00011-2020-0002','2886','2026-03-06','A2',55552.5,55306.05,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-07','00011-2020-0002','2886','2026-03-07','A3',55306.05,53580.21,'BGN');`,
+ );
+ runFullDerive(db);
+
+ // All three annexes now carry the resolved contract_number.
+ expect(
+ sqliteJson<{ n: number }>(
+ db,
+ "SELECT COUNT(*) AS n FROM raw_amendments WHERE unp='00011-2020-0002' AND contract_number='388-2020'",
+ ),
+ ).toEqual([{ n: 3 }]);
+ // annex_count = 3, current_value = the latest step's after-value.
+ expect(
+ sqliteJson<{ annex_count: number; current_value: number }>(
+ db,
+ "SELECT annex_count, current_value FROM raw_contracts WHERE contract_number='388-2020'",
+ ),
+ ).toEqual([{ annex_count: 3, current_value: 53580.21 }]);
+ });
+
+ it('propagates the target to a VALUE-LESS chain member so the chain does not break (review MEDIUM 2)', () => {
+ // A1 carries value_before = signing (unique anchor → 388-2020); A2 is an administrative annex with NO
+ // value_before (a term/scope change). A2 shares the annex number 2886, so it must INHERIT 388-2020 —
+ // else the chain breaks and annex_count under-counts.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00011-2020-0002','387-2020',64000,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00011-2020-0002','388-2020',56000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00011-2020-0002','2886','2026-03-05','A1',56000,55552.5,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-06','00011-2020-0002','2886','2026-03-06','A2',NULL,NULL,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ document_number: string; contract_number: string; link_method: string | null }>(
+ db,
+ 'SELECT document_number, contract_number, link_method FROM raw_amendments ORDER BY document_number',
+ ),
+ ).toEqual([
+ { document_number: 'A1', contract_number: '388-2020', link_method: 'value_anchor' },
+ { document_number: 'A2', contract_number: '388-2020', link_method: 'value_anchor' },
+ ]);
+ expect(
+ sqliteJson<{ annex_count: number }>(
+ db,
+ "SELECT annex_count FROM raw_contracts WHERE contract_number='388-2020'",
+ ),
+ ).toEqual([{ annex_count: 2 }]);
+ });
+
+ it('does NOT attach an ambiguous chain member to the group target (review todorkolev #2)', () => {
+ // Procedure: C-1 @ 500, C-2 @ 500, C-3 @ 1000. Annex 999: A1 value_before 1000 → C-3 (unique anchor);
+ // A2 value_before 500 → matches C-1 AND C-2 (ambiguous). A2 must stay unlinked — it carries its own
+ // contradicting evidence — and must NOT corrupt C-3's current_value.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00050-2020-0001','C-1',500,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00050-2020-0001','C-2',500,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00050-2020-0001','C-3',1000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00050-2020-0001','999','2026-03-05','A1',1000,900,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-06','00050-2020-0001','999','2026-03-06','A2',500,450,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ document_number: string; contract_number: string }>(
+ db,
+ 'SELECT document_number, contract_number FROM raw_amendments ORDER BY document_number',
+ ),
+ ).toEqual([
+ { document_number: 'A1', contract_number: 'C-3' }, // unique anchor linked
+ { document_number: 'A2', contract_number: '999' }, // ambiguous → stays unlinked
+ ]);
+ // C-3 keeps A1 only; current_value is A1's after-value (900), NOT the ambiguous A2's 450.
+ expect(
+ sqliteJson<{ annex_count: number; current_value: number }>(
+ db,
+ "SELECT annex_count, current_value FROM raw_contracts WHERE contract_number='C-3'",
+ ),
+ ).toEqual([{ annex_count: 1, current_value: 900 }]);
+ });
+
+ it('links each annex of a lot-base group to its OWN lot by its own unique match (real 00026 shape)', () => {
+ // The annex-side number is a LOT-BASE shared across two lots (real corpus: 20РП-У50А015 → …-Л01 @
+ // 22569.98 AND …-Л03 @ 28557.50). Each annex exactly-uniquely matches its OWN lot, so BOTH link — the
+ // "disagreement" between siblings is benign and must NOT void the individually-trustworthy direct hits.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00026-2020-0027','20РП-У50А015-Л01',22569.98,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00026-2020-0027','20РП-У50А015-Л03',28557.5,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00026-2020-0027','20РП-У50А015','2026-03-05','A1',22569.98,22000,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-06','00026-2020-0027','20РП-У50А015','2026-03-06','A2',28557.5,28000,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ document_number: string; contract_number: string }>(
+ db,
+ "SELECT document_number, contract_number FROM raw_amendments WHERE unp='00026-2020-0027' ORDER BY document_number",
+ ),
+ ).toEqual([
+ { document_number: 'A1', contract_number: '20РП-У50А015-Л01' },
+ { document_number: 'A2', contract_number: '20РП-У50А015-Л03' },
+ ]);
+ expect(
+ sqliteJson<{ contract_number: string; annex_count: number }>(
+ db,
+ "SELECT contract_number, annex_count FROM raw_contracts WHERE unp='00026-2020-0027' ORDER BY contract_number",
+ ),
+ ).toEqual([
+ { contract_number: '20РП-У50А015-Л01', annex_count: 1 },
+ { contract_number: '20РП-У50А015-Л03', annex_count: 1 },
+ ]);
+ });
+
+ it('withholds PROPAGATION when the group disagrees, but keeps the direct hits (review MEDIUM 1, revised)', () => {
+ // Two direct hits disagree (lot-base spread), plus a value-less admin annex A3 sharing the number. The
+ // two direct hits still link to their own lots; A3 has no agreed target to inherit → stays unlinked.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00060-2020-0001','D-1',100,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00060-2020-0001','D-2',200,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00060-2020-0001','777','2026-03-05','A1',100,90,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-06','00060-2020-0001','777','2026-03-06','A2',200,180,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-07','00060-2020-0001','777','2026-03-07','A3',NULL,NULL,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ document_number: string; contract_number: string }>(
+ db,
+ "SELECT document_number, contract_number FROM raw_amendments WHERE unp='00060-2020-0001' ORDER BY document_number",
+ ),
+ ).toEqual([
+ { document_number: 'A1', contract_number: 'D-1' }, // own unique match stands
+ { document_number: 'A2', contract_number: 'D-2' }, // own unique match stands
+ { document_number: 'A3', contract_number: '777' }, // no agreed target → no propagation
+ ]);
+ expect(
+ sqliteJson<{ contract_number: string; annex_count: number }>(
+ db,
+ "SELECT contract_number, annex_count FROM raw_contracts WHERE unp='00060-2020-0001' ORDER BY contract_number",
+ ),
+ ).toEqual([
+ { contract_number: 'D-1', annex_count: 1 },
+ { contract_number: 'D-2', annex_count: 1 },
+ ]);
+ });
+
+ it('counts cumulative-staging duplicates of one contract as a SINGLE candidate (review HIGH 2)', () => {
+ // The same logical contract is staged from TWO daily EOP buckets (raw_contracts is cumulative). Without
+ // deduping candidates, the lone annex would see n_match = 2 and be refused; the resolver must collapse
+ // the duplicate to one candidate and link.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00070-2020-0001','FILING-7',12345,'BGN'),
+ ('eop:contracts:2026-03-06','2026-03-06','00070-2020-0001','FILING-7',12345,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-06','2026-03-06','00070-2020-0001','88001','2026-03-06','A1',12345,12000,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: 'FILING-7' }]);
+ });
+
+ it('applies an EIK guard — a value match onto a different contractor is refused (review MEDIUM 5)', () => {
+ // Same unp, value, currency, but the annex is contractor 222222222 and the only value-matching contract
+ // is contractor 111111111 → the row's own EIK contradicts the target → no link.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency, contractor_eik) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00080-2020-0001','FILING-8',9000,'BGN','111111111');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency, contractor_eik) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00080-2020-0001','44001','2026-03-05','A1',9000,8500,'BGN','222222222');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '44001' }]); // unchanged → EIK guard held
+ });
+
+ it('leaves a value-AMBIGUOUS annex unlinked (two contracts share the signing value)', () => {
+ // Both contracts on the procedure have signing 5000; the annex value_before 5000 matches BOTH — the
+ // resolver must refuse (an honest gap beats a coin-flip).
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00099-2020-0001','LOT-A',5000,'BGN'),
+ ('eop:contracts:2026-03-05','2026-03-05','00099-2020-0001','LOT-B',5000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00099-2020-0001','7777','2026-03-05','A1',5000,4800,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '7777' }]); // unchanged → stays unlinked
+ expect(
+ sqliteJson<{ total: number }>(
+ db,
+ "SELECT COALESCE(SUM(annex_count),0) AS total FROM raw_contracts WHERE unp='00099-2020-0001'",
+ ),
+ ).toEqual([{ total: 0 }]);
+ });
+
+ it('leaves a NO-MATCH annex unlinked (value_before matches no contract on the procedure)', () => {
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00099-2020-0002','30',700000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00099-2020-0002','24035','2026-03-05','A1',123456,120000,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '24035' }]); // unchanged
+ });
+
+ it('holds the exact-cent tolerance — a value 3 BGN off does NOT match (pins the 0.005 constant)', () => {
+ // signing 5000, value_before 5003 → 3.00 apart: far under a naive "within 5" band but far over the
+ // 0.5-стотинка exact gate. Must stay unlinked; this is the whole basis of the 99.99% precision claim.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00087-2020-0001','FILING-87',5000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00087-2020-0001','47001','2026-03-05','A1',5003,4800,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '47001' }]); // unchanged → 3 BGN is not an exact-cent match
+ });
+
+ it('applies a currency guard — a BGN-valued annex does not match a same-number EUR contract', () => {
+ // signing_value numerically equals value_before but the currencies differ → not a real value match.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00207-2020-0171','FILING-1',66820,'EUR');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00207-2020-0171','9001','2026-03-05','A1',66820,66000,'BGN');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '9001' }]); // unchanged → currency guard held
+ });
+
+ it('does not match blank-currency against blank-currency (review LOW 1)', () => {
+ // Both sides carry no explicit currency. Under so tight a gate the resolver must NOT silently agree via
+ // a 'BGN' default — an explicit currency is required on both sides.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00085-2020-0001','FILING-85',7500,'');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00085-2020-0001','45001','2026-03-05','A1',7500,7000,'');`,
+ );
+ runFullDerive(db);
+
+ expect(
+ sqliteJson<{ contract_number: string }>(
+ db,
+ "SELECT contract_number FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '45001' }]); // unchanged → no blank-vs-blank match
+ });
+
+ it('does NOT resurrect an OCDS twin: resolver runs before the prefer-EOP dedup (review todorkolev #1 blocker)', () => {
+ // Contract Д-226 already has an OCDS annex on it. An EOP annex carries the unlinked internal number
+ // 148846 and value_before = signing (5000). Because the resolver runs BEFORE the #286 prefer-EOP DELETE,
+ // the EOP annex is moved onto Д-226 first, the OCDS twin is then dropped, and annex_count = 1. If the
+ // order were reversed the twin would survive and annex_count would be 2 (the twin-dedup #303 gate would
+ // then hard-fail the whole derive).
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00090-2020-0001','Д-226',5000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('ocds:annexes:2026-03-05','2026-03-05','00090-2020-0001','Д-226','2026-03-05','ocds-e82gsb-1',5000,NULL,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-05','00090-2020-0001','148846','2026-03-05','148846-1',5000,4500,'BGN');`,
+ );
+ runFullDerive(db);
+
+ // Exactly one annex survives on Д-226 (the EOP one), and it drives current_value.
+ expect(
+ sqliteJson<{ annex_count: number; current_value: number }>(
+ db,
+ "SELECT annex_count, current_value FROM raw_contracts WHERE contract_number='Д-226'",
+ ),
+ ).toEqual([{ annex_count: 1, current_value: 4500 }]);
+ // No twin duplication: exactly one row lands on Д-226 for this procedure.
+ expect(
+ sqliteJson<{ n: number }>(
+ db,
+ "SELECT COUNT(*) AS n FROM raw_amendments WHERE unp='00090-2020-0001' AND contract_number='Д-226'",
+ ),
+ ).toEqual([{ n: 1 }]);
+ });
+
+ it('is GATED to the resolver script: derive-amendments.sql alone does not resolve (review HIGH 1)', () => {
+ // The slice path runs derive-amendments.sql WITHOUT the resolver. Running derive alone must leave a
+ // resolvable annex untouched — proving the link is gated to the full-derive-only resolver script.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00095-2020-0001','FILING-95',5800,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00095-2020-0001','11725','2026-03-05','A1',5800,5600,'BGN');`,
+ );
+ readScript(db, deriveAmendments); // derive ONLY — no resolver
+
+ expect(
+ sqliteJson<{ contract_number: string; link_method: string | null }>(
+ db,
+ "SELECT contract_number, link_method FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number: '11725', link_method: null }]); // untouched
+ });
+
+ it('is idempotent: a second full derive links nothing new and keeps annex_count stable', () => {
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00096-2020-0001','FILING-96',5800,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00096-2020-0001','11725','2026-03-05','A1',5800,5600,'BGN');`,
+ );
+ runFullDerive(db);
+ const secondPass = readScriptCapture(db, resolveAmendments);
+ readScript(db, deriveAmendments);
+
+ // Second pass over already-resolved staging: the row now links by number so grp is empty and no new
+ // rewrite happens. The diagnostic still reports the one value-linked row (link_method persists) and 0
+ // still unlinked — i.e. nothing new linked, nothing left over.
+ const twoCol = diagRows(secondPass).filter((r) => r.length === 2);
+ expect(twoCol[twoCol.length - 1]).toEqual([1, 0]);
+ // contract_number stays resolved; annex_count stable at 1; provenance preserved.
+ expect(
+ sqliteJson<{ annex_count: number }>(
+ db,
+ "SELECT annex_count FROM raw_contracts WHERE contract_number='FILING-96'",
+ ),
+ ).toEqual([{ annex_count: 1 }]);
+ expect(
+ sqliteJson<{ contract_number_raw: string; link_method: string }>(
+ db,
+ "SELECT contract_number_raw, link_method FROM raw_amendments WHERE document_number='A1'",
+ ),
+ ).toEqual([{ contract_number_raw: '11725', link_method: 'value_anchor' }]);
+ });
+
+ it('carries provenance through promote into the served amendments table (review MEDIUM 4)', () => {
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00097-2020-0001','FILING-97',5800,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00097-2020-0001','11725','2026-03-05','A1',5800,5600,'BGN');`,
+ );
+ runFullDerive(db);
+ readScript(db, promoteAmendments);
+
+ // The served row is enumerable as value-linked, and the original annex number survives as provenance.
+ expect(
+ sqliteJson<{ contract_number: string; contract_number_raw: string; link_method: string }>(
+ db,
+ "SELECT contract_number, contract_number_raw, link_method FROM amendments WHERE unp='00097-2020-0001'",
+ ),
+ ).toEqual([
+ { contract_number: 'FILING-97', contract_number_raw: '11725', link_method: 'value_anchor' },
+ ]);
+ // The audit count nikimilenkov asked for is a plain query on the served side.
+ expect(
+ sqliteJson<{ n: number }>(
+ db,
+ "SELECT COUNT(*) AS n FROM amendments WHERE link_method='value_anchor'",
+ ),
+ ).toEqual([{ n: 1 }]);
+ });
+
+ it('a resolved annex does not collide with a native annex sharing document_number on the target (review MEDIUM 3)', () => {
+ // Contract FILING-98 already has a native annex whose document_number is 'DOC-1'. An unlinked annex 55501
+ // resolves to FILING-98 and ALSO has document_number 'DOC-1'. Keying the resolved row on its original
+ // annex number keeps both rows distinct — the real annex is not silently dropped by the dedup.
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00098-2020-0001','FILING-98',5000,'BGN');
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00098-2020-0001','FILING-98','2026-03-04','DOC-1',6000,5500,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-05','00098-2020-0001','55501','2026-03-05','DOC-1',5000,4500,'BGN');`,
+ );
+ runFullDerive(db);
+
+ // Both annexes survive on FILING-98 (the native one and the value-linked one).
+ expect(
+ sqliteJson<{ annex_count: number }>(
+ db,
+ "SELECT annex_count FROM raw_contracts WHERE contract_number='FILING-98'",
+ ),
+ ).toEqual([{ annex_count: 2 }]);
+ });
+
+ it('never touches an annex that already links by contract_number, and emits the #306 diagnostic', () => {
+ sqlite(
+ db,
+ `INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency) VALUES
+ ('eop:contracts:2026-03-05','2026-03-05','00001-2020-0001','Д-100',5000,'BGN'), -- annex links by value (unlinked number)
+ ('eop:contracts:2026-03-05','2026-03-05','00002-2020-0001','Д-200',9000,'BGN'); -- annex already links by number
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05','2026-03-05','00001-2020-0001','55501','2026-03-05','A1',5000,4500,'BGN'),
+ ('eop:annexes:2026-03-05','2026-03-05','00002-2020-0001','Д-200','2026-03-05','A2',9000,8000,'BGN');`,
+ );
+ const out = readScriptCapture(db, resolveAmendments);
+ readScript(db, deriveAmendments);
+
+ // A1 got resolved; A2 (already linked by number) is untouched and keeps NULL provenance.
+ expect(
+ sqliteJson<{ document_number: string; contract_number: string; link_method: string | null }>(
+ db,
+ 'SELECT document_number, contract_number, link_method FROM raw_amendments ORDER BY document_number',
+ ),
+ ).toEqual([
+ { document_number: 'A1', contract_number: 'Д-100', link_method: 'value_anchor' },
+ { document_number: 'A2', contract_number: 'Д-200', link_method: null },
+ ]);
+ // Diagnostic: exactly 1 value-linked, 0 still unlinked. The resolver's only 2-column row.
+ const twoCol = diagRows(out).filter((r) => r.length === 2);
+ expect(twoCol[twoCol.length - 1]).toEqual([1, 0]);
+ });
+});
diff --git a/packages/db/src/amendments-ocds-link.test.ts b/packages/db/src/amendments-ocds-link.test.ts
new file mode 100644
index 00000000..e86eb5bb
--- /dev/null
+++ b/packages/db/src/amendments-ocds-link.test.ts
@@ -0,0 +1,243 @@
+// Issue #286 — OCDS amendments must link to their contract via the recovered УНП, without
+// double-counting the EOP annexes or letting an OCDS "before" value understate current_value.
+//
+// Runs the REAL scripts (derive-amendments.sql → promote-amendments.sql) against SQLite via the
+// sqlite3 CLI, exactly as the ETL does, so the bridge + prefer-EOP dedup + value semantics are
+// exercised as shipped — not a hand-copied mirror.
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const initSchema = resolve(root, 'packages/db/migrations/0000_init.sql');
+// #305 Tier-2: promote-amendments.sql writes value_restated/value_treatment to served amendments.
+const migration6 = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const migration7 = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8 = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+const workStagingSchema = resolve(root, 'scripts/work-staging-schema.sql');
+const deriveAmendments = resolve(root, 'scripts/derive-amendments.sql');
+const promoteAmendments = resolve(root, 'scripts/promote-amendments.sql');
+
+function sqlite(dbPath: string, sql: string): void {
+ execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ // Enforce FK constraints (mirrors refresh-slice.test.ts) so the promotion is validated against the
+ // served schema, not run with FK checks silently off.
+ execFileSync('sqlite3', [dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+// Captures sqlite3 stdout (the SELECT results a `.read` prints) so a test can assert the diagnostic
+// numbers the ETL emits, not just the table state.
+function readScriptCapture(dbPath: string, path: string): string {
+ return execFileSync('sqlite3', [dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ encoding: 'utf8',
+ });
+}
+
+// The numeric rows derive-amendments.sql prints (its diagnostics + the final summary), split into integer
+// arrays and identified by column count: 1 col = ocds_ambiguous_bridges, 2 = dropped/excess-over-eop,
+// 4 = the run summary. sqlite3's default output separates columns by '|' and rows by newlines.
+function diagRows(out: string): number[][] {
+ return out
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => /^\d+(\|\d+)*$/.test(line))
+ .map((line) => line.split('|').map(Number));
+}
+
+// EOP УНП for the twin contract (90029), the OCDS-only contract (55500), and the OCDS-only contract
+// whose procedure is contract-only — a "synthetic tender" absent from raw_tenders (77700).
+const UNP_TWIN = '00044-2022-0146';
+const UNP_ONLY = '00099-2022-0009';
+const UNP_SYNTH = '00077-2022-0007';
+// A fourth OCDS annex whose procedure bridges NOWHERE — its tender.id is in neither raw_tenders nor
+// raw_contracts. On the live corpus ~3/4,800 OCDS amendments are like this (#286 links 4,797/4,800);
+// they honestly keep the OCID in `unp` because they can't be keyed to a contract yet — NOT dropped.
+const OCID_UNBRIDGED = 'ocds-e82gsb-666666';
+
+let dir: string;
+let db: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(resolve(tmpdir(), 'amendments-ocds-'));
+ db = resolve(dir, 'work.sqlite');
+ readScript(db, initSchema); // served `amendments` + `contracts`
+ readScript(db, migration6); // #305 Tier-2 value_restated/value_treatment on served amendments
+ readScript(db, migration7); // #305 residual value_suspect on served amendments
+ readScript(db, migration8); // #306 provenance columns on served amendments
+ readScript(db, workStagingSchema); // raw_* staging
+
+ // Two EOP procedures: T1 (contract 90029) already has an EOP annex; T2 (contract 55500) has NONE.
+ sqlite(
+ db,
+ `INSERT INTO raw_tenders (source, fetched_at, tender_id, unp) VALUES
+ ('eop:tenders:2026-03-05', '2026-03-05', 'T1', '${UNP_TWIN}'),
+ ('eop:tenders:2026-03-05', '2026-03-05', 'T2', '${UNP_ONLY}');
+ INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency, tender_ext_id) VALUES
+ ('eop:contracts:2026-03-05', '2026-03-05', '${UNP_TWIN}', '90029', 21602081.98, 'EUR', 'T1'),
+ ('eop:contracts:2026-03-05', '2026-03-05', '${UNP_ONLY}', '55500', 500000, 'EUR', 'T2'),
+ -- 77700's procedure is contract-only: it has NO raw_tenders row, so the bridge must fall back
+ -- to raw_contracts.tender_ext_id (T3) to recover the УНП.
+ ('eop:contracts:2026-03-05', '2026-03-05', '${UNP_SYNTH}', '77700', 300000, 'EUR', 'T3');
+ -- EOP annex for 90029 carries the correct after-value (27.4M).
+ INSERT INTO raw_amendments (source, fetched_at, unp, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('eop:annexes:2026-03-05', '2026-03-05', '${UNP_TWIN}', '90029', '2026-03-05', 'E1', 21602081.98, 27435415.31, 'EUR');
+ -- OCDS twin of the 90029 annex: unp is the OCID, value is the pre-amendment number stored as value_before.
+ INSERT INTO raw_amendments (source, fetched_at, unp, tender_ext_id, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('ocds:2026-03-05', '2026-03-05', 'ocds-e82gsb-245534', 'T1', '90029', '2026-03-05', 'O1', 21602081.98, NULL, 'EUR');
+ -- OCDS-only annex for 55500: exists in NO EOP feed. This is the row #286 wants to make visible.
+ INSERT INTO raw_amendments (source, fetched_at, unp, tender_ext_id, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('ocds:2026-04-01', '2026-04-01', 'ocds-e82gsb-999999', 'T2', '55500', '2026-04-01', 'O2', 480000, NULL, 'EUR');
+ -- OCDS-only annex for 77700 (contract-only procedure) — bridges via the raw_contracts fallback.
+ INSERT INTO raw_amendments (source, fetched_at, unp, tender_ext_id, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('ocds:2026-04-01', '2026-04-01', 'ocds-e82gsb-777777', 'T3', '77700', '2026-04-01', 'O3', 300000, NULL, 'EUR');
+ -- OCDS annex whose tender.id (T_MISSING) is in NEITHER raw_tenders nor raw_contracts — unbridgeable.
+ INSERT INTO raw_amendments (source, fetched_at, unp, tender_ext_id, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('ocds:2026-04-01', '2026-04-01', '${OCID_UNBRIDGED}', 'T_MISSING', '66600', '2026-04-01', 'O4', 300000, NULL, 'EUR');`,
+ );
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('OCDS amendment → contract linkage (issue #286)', () => {
+ it('bridges the УНП, drops EOP twins, and never understates current_value', () => {
+ readScript(db, deriveAmendments);
+
+ // Bridge: every *bridgeable* OCDS amendment now carries the real УНП. The one unbridgeable procedure
+ // (tender.id in neither raw_tenders nor raw_contracts) honestly keeps its OCID — it is NOT dropped.
+ const stillOcid = sqliteJson<{ contract_number: string; unp: string }>(
+ db,
+ "SELECT contract_number, unp FROM raw_amendments WHERE source LIKE 'ocds:%' AND unp LIKE 'ocds-%'",
+ );
+ expect(stillOcid).toEqual([{ contract_number: '66600', unp: OCID_UNBRIDGED }]);
+
+ // Prefer-EOP dedup: the 90029 OCDS twin is gone; the OCDS-only rows survive with their УНП —
+ // 55500 bridged via raw_tenders, 77700 via the raw_contracts synthetic-tender fallback, and 66600
+ // stays on its OCID (unbridgeable, but surfaced rather than silently deleted).
+ const ocds = sqliteJson<{ contract_number: string; unp: string }>(
+ db,
+ "SELECT contract_number, unp FROM raw_amendments WHERE source LIKE 'ocds:%' ORDER BY contract_number",
+ );
+ expect(ocds).toEqual([
+ { contract_number: '55500', unp: UNP_ONLY },
+ { contract_number: '66600', unp: OCID_UNBRIDGED },
+ { contract_number: '77700', unp: UNP_SYNTH },
+ ]);
+
+ // The value trap: 90029 rolls up the EOP after-value (27.4M), never the stale OCDS 21.6M.
+ const twin = sqliteJson<{ annex_count: number; current_value: number | null }>(
+ db,
+ "SELECT annex_count, current_value FROM raw_contracts WHERE contract_number = '90029'",
+ );
+ expect(twin[0]?.annex_count).toBe(1);
+ expect(twin[0]?.current_value).toBe(27435415.31);
+
+ // OCDS-only annex is now visible on its contract (annex_count = 1); current_value stays NULL because
+ // OCDS cannot know the after-value (honest — no fabricated figure).
+ const only = sqliteJson<{ annex_count: number; current_value: number | null }>(
+ db,
+ "SELECT annex_count, current_value FROM raw_contracts WHERE contract_number = '55500'",
+ );
+ expect(only[0]?.annex_count).toBe(1);
+ expect(only[0]?.current_value).toBeNull();
+ });
+
+ it('promotes served amendments, keeping only the unbridgeable OCID as the honest residual', () => {
+ readScript(db, deriveAmendments);
+ readScript(db, promoteAmendments);
+
+ // The issue's mass symptom is gone (4,800 → the handful with no bridge). The one procedure that
+ // bridges nowhere is promoted with its OCID still in unp — surfaced honestly, not silently dropped
+ // and not fabricated onto a contract. promote-amendments.sql intentionally promotes every staged row.
+ const dead = sqliteJson<{ contract_number: string; unp: string }>(
+ db,
+ "SELECT contract_number, unp FROM amendments WHERE unp LIKE 'ocds-%'",
+ );
+ expect(dead).toEqual([{ contract_number: '66600', unp: OCID_UNBRIDGED }]);
+
+ // The served rows: one EOP annex (90029), two bridged OCDS-only annexes (55500, 77700) keyed by real
+ // УНП, and the unbridgeable OCDS residual (66600) still on its OCID.
+ const served = sqliteJson<{ contract_number: string; unp: string; source: string }>(
+ db,
+ "SELECT contract_number, unp, CASE WHEN source LIKE 'ocds:%' THEN 'ocds' ELSE 'eop' END AS source FROM amendments ORDER BY contract_number",
+ );
+ expect(served).toEqual([
+ { contract_number: '55500', unp: UNP_ONLY, source: 'ocds' },
+ { contract_number: '66600', unp: OCID_UNBRIDGED, source: 'ocds' },
+ { contract_number: '77700', unp: UNP_SYNTH, source: 'ocds' },
+ { contract_number: '90029', unp: UNP_TWIN, source: 'eop' },
+ ]);
+ });
+
+ it('refuses to bridge (keeps the OCID) when a tender.id resolves to more than one УНП', () => {
+ // The domain is 1-to-1 (one procedure = one УНП). Give T2 a SECOND distinct УНП in raw_tenders. Rather
+ // than pick one arbitrarily (which would mis-attribute every annex of the losing procedure), the bridge
+ // must REFUSE: 55500's annex keeps its OCID as an honest residual, and the ocds_ambiguous_bridges
+ // diagnostic reports exactly one refusal (review nikimilenkov LOW 1).
+ sqlite(
+ db,
+ `INSERT INTO raw_tenders (source, fetched_at, tender_id, unp) VALUES
+ ('eop:tenders:2026-03-05', '2026-03-05', 'T2', 'ZZ-9999-9999');`,
+ );
+ const out = readScriptCapture(db, deriveAmendments);
+
+ const row = sqliteJson<{ unp: string }>(
+ db,
+ "SELECT unp FROM raw_amendments WHERE source LIKE 'ocds:%' AND contract_number = '55500'",
+ );
+ expect(row).toEqual([{ unp: 'ocds-e82gsb-999999' }]);
+
+ // The single-column diagnostic row reports the one ambiguous refusal.
+ expect(diagRows(out).find((r) => r.length === 1)).toEqual([1]);
+ });
+
+ it('recovers the УНП from raw_tenders in preference to raw_contracts (COALESCE order)', () => {
+ // One tender_ext_id (TP) resolves to DIFFERENT УНП in the two tables. The bridge tries raw_tenders
+ // first, so it must recover UNP-FROM-TENDERS — swapping the two COALESCE arms would fail this (#286).
+ sqlite(
+ db,
+ `INSERT INTO raw_tenders (source, fetched_at, tender_id, unp) VALUES
+ ('eop:tenders:2026-03-05', '2026-03-05', 'TP', 'UNP-FROM-TENDERS');
+ INSERT INTO raw_contracts (source, fetched_at, unp, contract_number, signing_value, currency, tender_ext_id) VALUES
+ ('eop:contracts:2026-03-05', '2026-03-05', 'UNP-FROM-CONTRACTS', 'PREC-1', 100, 'EUR', 'TP');
+ INSERT INTO raw_amendments (source, fetched_at, unp, tender_ext_id, contract_number, published_at, document_number, value_before, value_after, currency) VALUES
+ ('ocds:2026-04-01', '2026-04-01', 'ocds-e82gsb-tp', 'TP', 'PREC-1', '2026-04-01', 'OP', 100, NULL, 'EUR');`,
+ );
+ readScript(db, deriveAmendments);
+
+ expect(
+ sqliteJson<{ unp: string }>(
+ db,
+ "SELECT unp FROM raw_amendments WHERE contract_number = 'PREC-1'",
+ ),
+ ).toEqual([{ unp: 'UNP-FROM-TENDERS' }]);
+ });
+
+ it('emits the residual diagnostics (dropped / excess-over-eop / ambiguous) for monitoring', () => {
+ // The base fixture has exactly one twin (90029: one OCDS annex vs one EOP annex) → dropped = 1,
+ // excess-over-eop = 0; and no ambiguous procedures → 0. These are the numbers the PR promises are
+ // "surfaced, not hidden" — pin them so a wrong CASE branch, or moving a diagnostic SELECT below the
+ // DELETE (which would zero the dropped/excess counts), fails CI (review nikimilenkov MEDIUM 4).
+ const out = readScriptCapture(db, deriveAmendments);
+ const rows = diagRows(out);
+ expect(rows.find((r) => r.length === 2)).toEqual([1, 0]); // dropped, excess-over-eop
+ expect(rows.find((r) => r.length === 1)).toEqual([0]); // ocds_ambiguous_bridges
+ });
+});
diff --git a/packages/db/src/amendments-slice-resolve.test.ts b/packages/db/src/amendments-slice-resolve.test.ts
new file mode 100644
index 00000000..93dee1db
--- /dev/null
+++ b/packages/db/src/amendments-slice-resolve.test.ts
@@ -0,0 +1,305 @@
+// Issue #306 (PR #308 review todorkolev "дневните обновявания") — the value-anchor resolver must also run on
+// the daily/slice + Worker path, not only the full derive. `scripts/refresh-slice.sql` carries a corpus-safe
+// resolver whose candidate contracts come from the served `contracts` table (the whole corpus) UNIONed with
+// the current window's raw_contracts, so "unique on the procedure" is asked corpus-wide — closing the gap that
+// kept the full-path resolver off the slice (windowed raw_contracts could only answer "unique in the window").
+//
+// These tests run the REAL refresh-slice.sql against SQLite via the sqlite3 CLI, exactly as the CLI slice path
+// and the Worker compose it: a first window promotes a served corpus, then a later window brings a
+// namespace-mismatched annex (its annex-side number is in a different namespace than the contract's filing
+// number). The annex links by the exact value_before → signing_value anchor, keeps its provenance, and rolls
+// onto the prior-window target — while a corpus-ambiguous annex that would look unique in the window stays
+// honestly unlinked.
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const migrations = [
+ 'packages/db/migrations/0000_init.sql',
+ 'packages/db/migrations/0001_flow_pairs_bidder_index.sql',
+ 'packages/db/migrations/0002_current_value_currency.sql',
+ 'packages/db/migrations/0003_related_persons_foundation.sql',
+ // #305: refresh-slice.sql writes value_restated/value_treatment/value_suspect into served amendments.
+ 'packages/db/migrations/0006_amendment_restated.sql',
+ 'packages/db/migrations/0007_amendment_value_suspect.sql',
+ 'packages/db/migrations/0008_amendment_provenance.sql',
+ // #279/ADR-0033: refresh-slice.sql reads interest_link_evidence.
+ 'packages/db/migrations/0009_interest_link_evidence.sql',
+].map((p) => resolve(root, p));
+const workStagingSchema = resolve(root, 'scripts/work-staging-schema.sql');
+const refreshSlice = resolve(root, 'scripts/refresh-slice.sql');
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', [dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function sqlite(dbPath: string, sql: string): void {
+ execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+// Fresh transient staging for a new window, exactly as import.mjs / the Worker do between refreshes.
+function resetStaging(dbPath: string): void {
+ const rows = sqliteJson<{ name: string }>(
+ dbPath,
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'raw_%' ORDER BY name DESC",
+ );
+ for (const row of rows) sqlite(dbPath, `DROP TABLE IF EXISTS "${row.name}";`);
+ readScript(dbPath, workStagingSchema);
+}
+
+// One EOP procedure header (a tender) so contract promotion has an authority + tender to attach to.
+function seedTender(dbPath: string, unp: string, authorityEik: string): void {
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, dataset_year, fetched_at, unp, tender_id, procedure_type, procurement_subject,
+ cpv_code, cpv_description, contract_kind, estimated_value, currency, authority_name,
+ authority_eik, authority_type, published_at)
+ VALUES
+ ('eop:tenders:2026-06-01', 2026, '2026-06-07T00:00:00Z', '${unp}', 'T-${unp}', 'open',
+ 'Subject ${unp}', '45000000', 'Construction', 'works', 5000, 'BGN', 'Authority ${unp}',
+ '${authorityEik}', 'public', '2026-06-01');`,
+ );
+}
+
+// An EOP base contract. `cnum` is the contract's filing number; `signing` its signing value.
+function seedContract(
+ dbPath: string,
+ opts: { unp: string; cnum: string; signing: number; authorityEik: string; contractorEik: string },
+): void {
+ const { unp, cnum, signing, authorityEik, contractorEik } = opts;
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_contracts
+ (source, dataset_year, dataset_variant, fetched_at, needs_enrichment, document_number,
+ published_at, unp, tender_ext_id, procedure_type, procurement_subject, cpv_code,
+ cpv_description, contract_kind, estimated_value, procurement_currency, authority_name,
+ authority_eik, authority_type, contract_number, contract_date, signing_value, currency,
+ contract_subject, awarded_to_group, contractor_eik, contractor_name)
+ VALUES
+ ('eop:contracts:2026-06-01', 2026, 'eop', '2026-06-07T00:00:00Z', 0, 'DOC-${cnum}',
+ '2026-06-01', '${unp}', 'T-${unp}', 'open', 'Subject ${unp}', '45000000', 'Construction',
+ 'works', 5000, 'BGN', 'Authority ${unp}', '${authorityEik}', 'public', '${cnum}',
+ '2026-06-02', ${signing}, 'BGN', 'Contract ${cnum}', 0, '${contractorEik}', 'Bidder ${unp}');`,
+ );
+}
+
+// A namespace-mismatched EOP annex: `annexCnum` is the annex-side internal number (matches no contract by
+// number); it must link to a contract by the value_before anchor instead.
+function seedAnnex(
+ dbPath: string,
+ opts: {
+ unp: string;
+ annexCnum: string;
+ valueBefore: number;
+ valueAfter: number;
+ authorityEik: string;
+ contractorEik: string;
+ },
+): void {
+ const { unp, annexCnum, valueBefore, valueAfter, authorityEik, contractorEik } = opts;
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_amendments
+ (source, dataset_year, dataset_variant, fetched_at, seq_no, document_number, contract_number,
+ contract_date, published_at, unp, authority_eik, authority_name, procurement_subject,
+ contract_kind, value_before, value_after, value_delta, currency, contractor_eik, description)
+ VALUES
+ ('eop:annexes:2026-06-08', 2026, 'eop', '2026-06-08T00:00:00Z', '1', 'AMD-${annexCnum}',
+ '${annexCnum}', '2026-06-02', '2026-06-09', '${unp}', '${authorityEik}', 'Authority ${unp}',
+ 'Subject ${unp}', 'works', ${valueBefore}, ${valueAfter}, ${valueAfter - valueBefore}, 'BGN',
+ '${contractorEik}', 'Namespace-mismatched annex');`,
+ );
+}
+
+let dir: string;
+let db: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(resolve(tmpdir(), 'amendments-slice-resolve-'));
+ db = resolve(dir, 'work.sqlite');
+ for (const m of migrations) readScript(db, m);
+ readScript(db, workStagingSchema);
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+interface AmendmentRow {
+ contract_number: string;
+ contract_number_raw: string | null;
+ link_method: string | null;
+}
+interface ContractRow {
+ contract_number: string;
+ annex_count: number;
+ current_value: number | null;
+}
+
+describe('refresh-slice #306 value-anchor resolver', () => {
+ it('links a window annex to a prior-window served contract by the corpus value anchor', () => {
+ // Window 1: the base contract, filing number Д-226, is served on its own.
+ seedTender(db, 'UNP-NS', '123456786');
+ seedContract(db, {
+ unp: 'UNP-NS',
+ cnum: 'Д-226',
+ signing: 1000,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ readScript(db, refreshSlice);
+
+ // Window 2: only the annex arrives, carrying the internal number 148846 — matches no contract by number,
+ // but its value_before (1000) is the served contract's signing_value.
+ resetStaging(db);
+ seedAnnex(db, {
+ unp: 'UNP-NS',
+ annexCnum: '148846',
+ valueBefore: 1000,
+ valueAfter: 1200,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ readScript(db, refreshSlice);
+
+ const amendments = sqliteJson(
+ db,
+ "SELECT contract_number, contract_number_raw, link_method FROM amendments WHERE unp='UNP-NS'",
+ );
+ // Rewritten onto the target contract's filing number, with the annex-side number kept as provenance.
+ expect(amendments).toEqual([
+ { contract_number: 'Д-226', contract_number_raw: '148846', link_method: 'value_anchor' },
+ ]);
+
+ // The prior-window target was touched and re-rolled: the annex now counts and drives current_value.
+ const contract = sqliteJson(
+ db,
+ "SELECT contract_number, annex_count, current_value FROM contracts WHERE tender_id='t:UNP-NS'",
+ );
+ expect(contract).toEqual([{ contract_number: 'Д-226', annex_count: 1, current_value: 1200 }]);
+ });
+
+ it('leaves a corpus-ambiguous annex unlinked even when it is unique within the window', () => {
+ // Window 1: two served contracts on the SAME procedure, both signing_value 1000 — the annex value cannot
+ // pick between them across the corpus.
+ seedTender(db, 'UNP-AMB', '123456786');
+ seedContract(db, {
+ unp: 'UNP-AMB',
+ cnum: 'Д-1',
+ signing: 1000,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ seedContract(db, {
+ unp: 'UNP-AMB',
+ cnum: 'Д-2',
+ signing: 1000,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ readScript(db, refreshSlice);
+
+ // Window 2: a THIRD matching contract Д-3 (1000) plus the mismatched annex. A windowed-only resolver would
+ // see just Д-3 and mislink (n_match = 1); the corpus-aware resolver sees Д-1/Д-2/Д-3 and refuses.
+ resetStaging(db);
+ seedContract(db, {
+ unp: 'UNP-AMB',
+ cnum: 'Д-3',
+ signing: 1000,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ seedAnnex(db, {
+ unp: 'UNP-AMB',
+ annexCnum: '148846',
+ valueBefore: 1000,
+ valueAfter: 1200,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ readScript(db, refreshSlice);
+
+ // Unlinked: the annex keeps its raw annex-side number and no link_method.
+ const amendments = sqliteJson(
+ db,
+ "SELECT contract_number, contract_number_raw, link_method FROM amendments WHERE unp='UNP-AMB'",
+ );
+ expect(amendments).toEqual([
+ { contract_number: '148846', contract_number_raw: null, link_method: null },
+ ]);
+
+ // None of the three candidate contracts absorbed the annex.
+ const amended = sqliteJson<{ n: number }>(
+ db,
+ "SELECT COUNT(*) AS n FROM contracts WHERE tender_id='t:UNP-AMB' AND annex_count > 0",
+ );
+ expect(amended).toEqual([{ n: 0 }]);
+ });
+
+ it('keeps an annex on its zero-value contract it matches BY NUMBER (never value-links to a neighbour)', () => {
+ // The full path excludes an annex whose (unp, contract_number) is a real contract, regardless of that
+ // contract's value. The slice path must agree: Д-1 has signing_value 0, Д-2 has 5000; the annex is numbered
+ // Д-1 with value_before 5000. It matches Д-1 by number, so it must stay on Д-1 — NOT get value-linked to Д-2
+ // just because Д-1 fails the signing_value > 0 candidate filter (review todorkolev: the paths must agree).
+ seedTender(db, 'UNP-ZERO', '123456786');
+ seedContract(db, {
+ unp: 'UNP-ZERO',
+ cnum: 'Д-2',
+ signing: 5000,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ readScript(db, refreshSlice);
+
+ // Window 2: the zero-value contract Д-1 arrives with the annex that carries its number.
+ resetStaging(db);
+ seedContract(db, {
+ unp: 'UNP-ZERO',
+ cnum: 'Д-1',
+ signing: 0,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ seedAnnex(db, {
+ unp: 'UNP-ZERO',
+ annexCnum: 'Д-1',
+ valueBefore: 5000,
+ valueAfter: 9000,
+ authorityEik: '123456786',
+ contractorEik: '987654308',
+ });
+ readScript(db, refreshSlice);
+
+ // The annex stays on Д-1 by number — not rewritten to the value-neighbour Д-2.
+ const amendments = sqliteJson(
+ db,
+ "SELECT contract_number, contract_number_raw, link_method FROM amendments WHERE unp='UNP-ZERO'",
+ );
+ expect(amendments).toEqual([
+ { contract_number: 'Д-1', contract_number_raw: null, link_method: null },
+ ]);
+
+ // Д-1 absorbs the annex; the value-neighbour Д-2 is untouched.
+ const perContract = sqliteJson<{ contract_number: string; annex_count: number }>(
+ db,
+ "SELECT contract_number, annex_count FROM contracts WHERE tender_id='t:UNP-ZERO' ORDER BY contract_number",
+ );
+ expect(perContract).toEqual([
+ { contract_number: 'Д-1', annex_count: 1 },
+ { contract_number: 'Д-2', annex_count: 0 },
+ ]);
+ });
+});
diff --git a/packages/db/src/amendments-sql.test.ts b/packages/db/src/amendments-sql.test.ts
index 2d161410..9e54acdd 100644
--- a/packages/db/src/amendments-sql.test.ts
+++ b/packages/db/src/amendments-sql.test.ts
@@ -16,6 +16,14 @@ import { AMENDMENTS_SQL } from './queries/details';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
+// #305 Tier-2: AMENDMENTS_SQL selects am.value_restated, added to served amendments by this migration.
+const migration6 = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+// #305 residual: AMENDMENTS_SQL also selects am.value_suspect, added by this migration.
+const migration7 = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8 = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+// #279/ADR-0033: refresh-slice.sql + normalize-raw.sql read interest_link_evidence, so 0009 must be applied too.
+const migration9 = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
function sqlite(dbPath: string, sql: string): string {
return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' });
@@ -65,6 +73,10 @@ function withDb(fn: (dbPath: string) => T): T {
const dbPath = resolve(dir, 'test.sqlite');
try {
readScript(dbPath, migration0);
+ readScript(dbPath, migration6);
+ readScript(dbPath, migration7);
+ readScript(dbPath, migration8);
+ readScript(dbPath, migration9);
return fn(dbPath);
} finally {
rmSync(dir, { recursive: true, force: true });
diff --git a/packages/db/src/amendments-total-restated.test.ts b/packages/db/src/amendments-total-restated.test.ts
new file mode 100644
index 00000000..1212ea33
--- /dev/null
+++ b/packages/db/src/amendments-total-restated.test.ts
@@ -0,0 +1,307 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// #305 Tier-2 text-based value correction. Some ЦАИС ЕОП annexes put the announced NEW TOTAL into the
+// change field, so the feed's value_after is doubled. The основание-text heuristic (computed in TS
+// ingest, packages/ingest/src/amendment-total.ts) classifies each annex and — because the correction is
+// computed BEFORE the SQL runs — the raw row lands with value_treatment + value_after_restated already
+// set. This suite simulates that ingest output and drives the REAL derive → normalize/refresh-slice →
+// promote → precompute scripts in pipeline order on a real SQLite DB, asserting:
+// (a) a total_restated annex drives current_value + served value_after with the corrected total and is
+// NOT annex_total_suspect;
+// (b) a genuine_increment annex is not flagged and keeps its (larger, correct) value_after;
+// (c) an untreated doubled annex still gets the Tier-1 annex_total_suspect flag;
+// (d) full-vs-slice parity for the restated contract.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
+const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
+const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+// #279/ADR-0033: refresh-slice.sql + normalize-raw.sql read interest_link_evidence, so 0009 must be applied too.
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
+const derivePath = resolve(root, 'scripts/derive-amendments.sql');
+const normalizePath = resolve(root, 'scripts/normalize-raw.sql');
+const promotePath = resolve(root, 'scripts/promote-amendments.sql');
+const precomputePath = resolve(root, 'scripts/precompute.sql');
+const refreshSlicePath = resolve(root, 'scripts/refresh-slice.sql');
+
+const etlRuns = [
+ ['normalize-raw', [derivePath, normalizePath, promotePath, precomputePath]],
+ ['refresh-slice', [derivePath, refreshSlicePath, precomputePath]],
+] as const;
+
+function sqlite(dbPath: string, sql: string): string {
+ return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', ['-bail', dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function withEtlDb(label: string, run: (dbPath: string) => void): void {
+ const dir = mkdtempSync(resolve(tmpdir(), `sigma-totalrestated-${label}-`));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ readScript(dbPath, schemaPath);
+ readScript(dbPath, migration1Path);
+ readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, stagingPath);
+ run(dbPath);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+const AUTH_EIK = '000695114';
+const BIDDER_EIK = '831646048';
+
+interface AmendmentStep {
+ before: number | null;
+ after: number;
+ publishedAt: string;
+ treatment: string | null;
+ restatedAfter: number | null;
+}
+
+interface Case {
+ unp: string;
+ signing: number;
+ steps: AmendmentStep[];
+}
+
+function seedContracts(dbPath: string, cases: Case[]): void {
+ const tenders = cases
+ .map(
+ (c) =>
+ `('eop:tenders:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'public', ${c.signing}, 'BGN')`,
+ )
+ .join(',\n');
+ const contracts = cases
+ .map(
+ (c) =>
+ `('eop:contracts:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'C-${c.unp}', '2026-06-01', ${c.signing}, 'BGN', '${BIDDER_EIK}', 'Тестов изпълнител')`,
+ )
+ .join(',\n');
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, fetched_at, unp, authority_eik, authority_name, authority_type, estimated_value, currency)
+ VALUES ${tenders};
+
+ INSERT INTO raw_contracts
+ (source, fetched_at, unp, authority_eik, authority_name, contract_number,
+ contract_date, signing_value, currency, contractor_eik, contractor_name)
+ VALUES ${contracts};`,
+ );
+}
+
+// Seed raw_amendments with value_treatment + value_after_restated already populated — simulating the TS
+// ingest output (base.ts runs the amendment-total.ts heuristic before staging).
+function seedAmendments(dbPath: string, cases: Case[]): void {
+ const rows = cases
+ .flatMap((c) =>
+ c.steps.map(
+ (s, i) =>
+ `('eop:annexes:${c.unp}', '2026-06-01T00:00:00Z', 'A-${c.unp}-${i + 1}', '${c.unp}', 'C-${c.unp}', '${s.publishedAt}', ${s.before ?? 'NULL'}, ${s.after}, 'BGN', ${s.treatment === null ? 'NULL' : `'${s.treatment}'`}, ${s.restatedAfter ?? 'NULL'})`,
+ ),
+ )
+ .join(',\n');
+ if (!rows) return;
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_amendments
+ (source, fetched_at, document_number, unp, contract_number, published_at,
+ value_before, value_after, currency, value_treatment, value_after_restated)
+ VALUES ${rows};`,
+ );
+}
+
+interface ContractRow {
+ id: string;
+ value_flag: string;
+ current_value: number | null;
+}
+
+const contractsByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT id, value_flag, current_value FROM contracts`,
+ );
+ const out = new Map();
+ for (const r of rows) out.set(r.id.split(':')[2]!, r);
+ return out;
+};
+
+interface AmendmentRow {
+ unp: string;
+ value_after: number | null;
+ value_delta: number | null;
+ value_restated: number | null;
+}
+
+const amendmentsByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT unp, value_after, value_delta, value_restated FROM amendments`,
+ );
+ const out = new Map();
+ for (const r of rows) out.set(r.unp, r);
+ return out;
+};
+
+// (a) total_restated: doubled value_after (981240) but the основание text announced the true total
+// (539240). Ingest set value_after_restated=539240, value_treatment='total_restated'.
+const RESTATED: Case = {
+ unp: 'UNP-RESTATED',
+ signing: 442_000,
+ steps: [
+ {
+ before: 442_000,
+ after: 981_240,
+ publishedAt: '2026-06-10',
+ treatment: 'total_restated',
+ restatedAfter: 539_240,
+ },
+ ],
+};
+
+// (b) genuine_increment: value_after (60226.85) is a real ≥2× increase already applied; the text confirmed
+// it, so ingest set value_treatment='genuine_increment' with restatedAfter NULL. Must NOT be flagged.
+const GENUINE: Case = {
+ unp: 'UNP-GENUINE',
+ signing: 10_226.85,
+ steps: [
+ {
+ before: 10_226.85,
+ after: 60_226.85,
+ publishedAt: '2026-06-10',
+ treatment: 'genuine_increment',
+ restatedAfter: null,
+ },
+ ],
+};
+
+// (c) untreated doubled annex: no text signal, before ≈ signing, same currency → Tier-1 unchanged.
+const UNTREATED: Case = {
+ unp: 'UNP-DOUBLE',
+ signing: 77_000_000,
+ steps: [
+ {
+ before: 77_000_000,
+ after: 154_000_000,
+ publishedAt: '2026-06-10',
+ treatment: null,
+ restatedAfter: null,
+ },
+ ],
+};
+
+describe('#305 Tier-2 text-based amendment value correction', () => {
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: a total_restated annex drives the corrected total and is not flagged`, () => {
+ withEtlDb(label, (dbPath) => {
+ seedContracts(dbPath, [RESTATED]);
+ seedAmendments(dbPath, [RESTATED]);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const contract = contractsByUnp(dbPath).get('UNP-RESTATED');
+ expect(contract?.value_flag, 'restated annex is not arithmetic-flagged').toBe('ok');
+ expect(
+ contract?.current_value,
+ 'current_value is the corrected total, NOT the doubled value',
+ ).toBe(539_240);
+
+ const amendment = amendmentsByUnp(dbPath).get('UNP-RESTATED');
+ expect(amendment?.value_after, 'served value_after is the corrected total').toBe(539_240);
+ expect(amendment?.value_delta, 'served delta is self-consistent (after − before)').toBe(
+ 539_240 - 442_000,
+ );
+ expect(amendment?.value_restated, 'served row is marked restated').toBe(1);
+ });
+ });
+
+ it(`${label}: a genuine_increment annex is not flagged and keeps its value_after`, () => {
+ withEtlDb(label, (dbPath) => {
+ seedContracts(dbPath, [GENUINE]);
+ seedAmendments(dbPath, [GENUINE]);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const contract = contractsByUnp(dbPath).get('UNP-GENUINE');
+ expect(contract?.value_flag, 'confirmed-genuine increment is not flagged').not.toBe(
+ 'annex_total_suspect',
+ );
+ expect(contract?.current_value, 'current_value keeps the genuine increase').toBe(60_226.85);
+
+ const amendment = amendmentsByUnp(dbPath).get('UNP-GENUINE');
+ expect(amendment?.value_after, 'served value_after unchanged').toBe(60_226.85);
+ expect(amendment?.value_restated, 'genuine increment is not marked restated').toBe(0);
+ });
+ });
+
+ it(`${label}: an untreated doubled annex still gets the Tier-1 annex_total_suspect flag`, () => {
+ withEtlDb(label, (dbPath) => {
+ seedContracts(dbPath, [UNTREATED]);
+ seedAmendments(dbPath, [UNTREATED]);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const contract = contractsByUnp(dbPath).get('UNP-DOUBLE');
+ expect(contract?.value_flag, 'untreated double is still flagged').toBe(
+ 'annex_total_suspect',
+ );
+
+ const amendment = amendmentsByUnp(dbPath).get('UNP-DOUBLE');
+ expect(amendment?.value_restated, 'untreated double is not marked restated').toBe(0);
+ });
+ });
+ }
+
+ it('full-vs-slice parity: the total_restated contract resolves identically on both paths', () => {
+ let full: ContractRow | undefined;
+ withEtlDb('parity-full', (dbPath) => {
+ seedContracts(dbPath, [RESTATED]);
+ seedAmendments(dbPath, [RESTATED]);
+ for (const p of [derivePath, normalizePath, promotePath, precomputePath])
+ readScript(dbPath, p);
+ full = contractsByUnp(dbPath).get('UNP-RESTATED');
+ });
+
+ let slice: ContractRow | undefined;
+ withEtlDb('parity-slice', (dbPath) => {
+ seedContracts(dbPath, [RESTATED]);
+ seedAmendments(dbPath, [RESTATED]);
+ for (const p of [derivePath, refreshSlicePath, precomputePath]) readScript(dbPath, p);
+ slice = contractsByUnp(dbPath).get('UNP-RESTATED');
+ });
+
+ // Pin both paths to the concrete expected values — a cross-equality (full === slice) would also
+ // pass on dual-undefined, so assert against the literal on each path instead.
+ expect(full?.value_flag).toBe('ok');
+ expect(slice?.value_flag).toBe('ok');
+ expect(full?.current_value).toBe(539_240);
+ expect(slice?.current_value).toBe(539_240);
+ });
+});
diff --git a/packages/db/src/amendments-total-suspect.test.ts b/packages/db/src/amendments-total-suspect.test.ts
new file mode 100644
index 00000000..9b9b5f34
--- /dev/null
+++ b/packages/db/src/amendments-total-suspect.test.ts
@@ -0,0 +1,714 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// #305 single-annex value double-count: a driving annex whose value_after is >=2x its value_before is
+// a data defect — ЗОП чл.116 caps a single amendment at +50%, so one step cannot legally more than
+// double a contract. Such contracts get value_flag = 'annex_total_suspect' and fall back to
+// signing_value, exactly like annex_suspect, so the doubled figure is excluded from every EUR
+// aggregate. The ABS(value_after - current_value) tie binds the flag to the annex that DRIVES
+// current_value: a doubled annex later superseded by a correct one is NOT flagged.
+//
+// The flag CASE and its value-fallback siblings live in copies across both derive paths (normalize-raw
+// and refresh-slice) plus refresh-slice's reconciliation re-flag, so the rule is exercised through the
+// REAL scripts in pipeline order on a real SQLite database. A copy left behind fails here.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
+const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
+const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// #305 Tier-2: served amendments gained value_restated/value_treatment (promote + refresh-slice write them).
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+// #305 residual: served amendments gained value_suspect (promote + refresh-slice write it).
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+// #279/ADR-0033: refresh-slice.sql + normalize-raw.sql read interest_link_evidence, so 0009 must be applied too.
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
+const derivePath = resolve(root, 'scripts/derive-amendments.sql');
+const normalizePath = resolve(root, 'scripts/normalize-raw.sql');
+const promotePath = resolve(root, 'scripts/promote-amendments.sql');
+const precomputePath = resolve(root, 'scripts/precompute.sql');
+const refreshSlicePath = resolve(root, 'scripts/refresh-slice.sql');
+
+// Real pipeline order (scripts/import.mjs): full derive runs derive-amendments → normalize-raw →
+// promote-amendments → precompute; the slice derive runs derive-amendments → refresh-slice (which
+// promotes the window's amendments itself) → precompute. precompute populates current_value_eur, which
+// these assertions read.
+const etlRuns = [
+ ['normalize-raw', [derivePath, normalizePath, promotePath, precomputePath]],
+ ['refresh-slice', [derivePath, refreshSlicePath, precomputePath]],
+] as const;
+
+function sqlite(dbPath: string, sql: string): string {
+ return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', ['-bail', dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function withEtlDb(label: string, run: (dbPath: string) => void): void {
+ const dir = mkdtempSync(resolve(tmpdir(), `sigma-totalsuspect-${label}-`));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ readScript(dbPath, schemaPath);
+ readScript(dbPath, migration1Path);
+ readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, stagingPath);
+ run(dbPath);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+// Signing value in BGN; exactly 100_000 EUR at the fixed peg (÷1.95583) so the fallback reads directly.
+const SIGNING_BGN = 195_583;
+const AUTH_EIK = '000695114';
+const BIDDER_EIK = '831646048';
+
+interface AmendmentStep {
+ before: number | null;
+ after: number;
+ publishedAt: string;
+}
+
+interface Case {
+ unp: string;
+ signing: number;
+ steps: AmendmentStep[];
+}
+
+function seedContracts(dbPath: string, cases: Case[]): void {
+ const tenders = cases
+ .map(
+ (c) =>
+ `('eop:tenders:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'public', ${c.signing}, 'BGN')`,
+ )
+ .join(',\n');
+ const contracts = cases
+ .map(
+ (c) =>
+ `('eop:contracts:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'C-${c.unp}', '2026-06-01', ${c.signing}, 'BGN', '${BIDDER_EIK}', 'Тестов изпълнител')`,
+ )
+ .join(',\n');
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, fetched_at, unp, authority_eik, authority_name, authority_type, estimated_value, currency)
+ VALUES ${tenders};
+
+ INSERT INTO raw_contracts
+ (source, fetched_at, unp, authority_eik, authority_name, contract_number,
+ contract_date, signing_value, currency, contractor_eik, contractor_name)
+ VALUES ${contracts};`,
+ );
+}
+
+function seedAmendments(dbPath: string, cases: Case[]): void {
+ const rows = cases
+ .flatMap((c) =>
+ c.steps.map(
+ (s, i) =>
+ `('eop:annexes:${c.unp}', '2026-06-01T00:00:00Z', 'A-${c.unp}-${i + 1}', '${c.unp}', 'C-${c.unp}', '${s.publishedAt}', ${s.before ?? 'NULL'}, ${s.after}, 'BGN')`,
+ ),
+ )
+ .join(',\n');
+ if (!rows) return;
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_amendments
+ (source, fetched_at, document_number, unp, contract_number, published_at,
+ value_before, value_after, currency)
+ VALUES ${rows};`,
+ );
+}
+
+interface Row {
+ id: string;
+ value_flag: string;
+ amount_eur: number | null;
+ current_value_eur: number | null;
+}
+
+const rowsByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT id, value_flag, ROUND(amount_eur) AS amount_eur,
+ ROUND(current_value_eur) AS current_value_eur
+ FROM contracts`,
+ );
+ const out = new Map();
+ for (const r of rows) out.set(r.id.split(':')[2]!, r);
+ return out;
+};
+
+// #305 residual: seed raw_amendments including value_treatment + value_after_restated (the TS ingest
+// output) so the served value_suspect / value_restated marks can be asserted end-to-end.
+interface TreatedStep {
+ before: number | null;
+ after: number;
+ publishedAt: string;
+ treatment: string | null;
+ restatedAfter: number | null;
+}
+interface TreatedCase {
+ unp: string;
+ signing: number;
+ steps: TreatedStep[];
+}
+
+function seedTreatedContracts(dbPath: string, cases: TreatedCase[]): void {
+ seedContracts(
+ dbPath,
+ cases.map((c) => ({ unp: c.unp, signing: c.signing, steps: [] })),
+ );
+}
+
+function seedTreatedAmendments(dbPath: string, cases: TreatedCase[]): void {
+ const rows = cases
+ .flatMap((c) =>
+ c.steps.map(
+ (s, i) =>
+ `('eop:annexes:${c.unp}', '2026-06-01T00:00:00Z', 'A-${c.unp}-${i + 1}', '${c.unp}', 'C-${c.unp}', '${s.publishedAt}', ${s.before ?? 'NULL'}, ${s.after}, 'BGN', ${s.treatment === null ? 'NULL' : `'${s.treatment}'`}, ${s.restatedAfter ?? 'NULL'})`,
+ ),
+ )
+ .join(',\n');
+ if (!rows) return;
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_amendments
+ (source, fetched_at, document_number, unp, contract_number, published_at,
+ value_before, value_after, currency, value_treatment, value_after_restated)
+ VALUES ${rows};`,
+ );
+}
+
+interface ServedAmendment {
+ unp: string;
+ value_after: number | null;
+ value_suspect: number | null;
+ value_restated: number | null;
+}
+const servedByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT unp, value_after, value_suspect, value_restated FROM amendments`,
+ );
+ const out = new Map();
+ for (const r of rows) out.set(r.unp, r);
+ return out;
+};
+
+// (a) flag-only double: no основание signal (value_treatment NULL), before ≈ signing, value_after = 3×
+// before. We can't bridge the true total → mark value_suspect = 1, keep value_restated = 0, and the
+// served value_after stays the untrusted figure (the UI blanks it; the number is never rewritten).
+const FLAG_ONLY: TreatedCase = {
+ unp: 'UNP-FLAGONLY',
+ signing: 100,
+ steps: [
+ { before: 100, after: 300, publishedAt: '2026-06-10', treatment: null, restatedAfter: null },
+ ],
+};
+// (b) total_restated: text-treated → value_restated = 1, value_suspect = 0.
+const RESTATED: TreatedCase = {
+ unp: 'UNP-RESTATED',
+ signing: 442_000,
+ steps: [
+ {
+ before: 442_000,
+ after: 981_240,
+ publishedAt: '2026-06-10',
+ treatment: 'total_restated',
+ restatedAfter: 539_240,
+ },
+ ],
+};
+// (c) genuine_increment: text-confirmed real increase → both marks 0.
+const GENUINE: TreatedCase = {
+ unp: 'UNP-GENUINE',
+ signing: 10_226.85,
+ steps: [
+ {
+ before: 10_226.85,
+ after: 60_226.85,
+ publishedAt: '2026-06-10',
+ treatment: 'genuine_increment',
+ restatedAfter: null,
+ },
+ ],
+};
+
+describe('#305 residual: per-amendment value_suspect marker on the served row', () => {
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: a flag-only double is marked value_suspect=1, value_restated=0, value_after untouched`, () => {
+ withEtlDb(label, (dbPath) => {
+ seedTreatedContracts(dbPath, [FLAG_ONLY, RESTATED, GENUINE]);
+ seedTreatedAmendments(dbPath, [FLAG_ONLY, RESTATED, GENUINE]);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const served = servedByUnp(dbPath);
+
+ const flagOnly = served.get('UNP-FLAGONLY');
+ expect(flagOnly?.value_suspect, 'flag-only double is marked suspect').toBe(1);
+ expect(flagOnly?.value_restated, 'flag-only double is NOT restated').toBe(0);
+ expect(flagOnly?.value_after, 'served value_after is never rewritten').toBe(300);
+
+ const restated = served.get('UNP-RESTATED');
+ expect(restated?.value_restated, 'total_restated row is marked restated').toBe(1);
+ expect(restated?.value_suspect, 'a restated row is never also suspect').toBe(0);
+
+ const genuine = served.get('UNP-GENUINE');
+ expect(genuine?.value_suspect, 'genuine increment is not suspect').toBe(0);
+ expect(genuine?.value_restated, 'genuine increment is not restated').toBe(0);
+ });
+ });
+ }
+
+ it('full-vs-slice parity: the flag-only double gets value_suspect=1 on both paths', () => {
+ let full: ServedAmendment | undefined;
+ withEtlDb('parity-full', (dbPath) => {
+ seedTreatedContracts(dbPath, [FLAG_ONLY]);
+ seedTreatedAmendments(dbPath, [FLAG_ONLY]);
+ for (const p of [derivePath, normalizePath, promotePath, precomputePath])
+ readScript(dbPath, p);
+ full = servedByUnp(dbPath).get('UNP-FLAGONLY');
+ });
+
+ let slice: ServedAmendment | undefined;
+ withEtlDb('parity-slice', (dbPath) => {
+ seedTreatedContracts(dbPath, [FLAG_ONLY]);
+ seedTreatedAmendments(dbPath, [FLAG_ONLY]);
+ for (const p of [derivePath, refreshSlicePath, precomputePath]) readScript(dbPath, p);
+ slice = servedByUnp(dbPath).get('UNP-FLAGONLY');
+ });
+
+ // Pin both paths to the concrete expected value — a cross-equality (full === slice) would also pass
+ // on dual-undefined, so assert against the literal on each path instead.
+ expect(full?.value_suspect).toBe(1);
+ expect(slice?.value_suspect).toBe(1);
+ });
+});
+
+describe('#305 annex_total_suspect single-annex value double-count', () => {
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: flags a doubled driving annex and falls back to the signing value`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // (a) The bug: 77M signed, one annex reports value_after = 2x value_before (154M). ЗОП caps a
+ // single amendment at +50%, so this is a double-count defect: flag it and drop back to signing.
+ {
+ unp: 'UNP-DOUBLE',
+ signing: 77_000_000,
+ steps: [{ before: 77_000_000, after: 154_000_000, publishedAt: '2026-06-10' }],
+ },
+ // (b) A genuine small increase (+30%): before 100 → after 130. Must stay 'ok' and keep its
+ // current value, guarding against false positives.
+ {
+ unp: 'UNP-SMALL',
+ signing: 100,
+ steps: [{ before: 100, after: 130, publishedAt: '2026-06-10' }],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const rows = rowsByUnp(dbPath);
+
+ // (a) flagged, value base falls back to signing (77M BGN ÷1.95583), NOT the doubled 154M.
+ const doubled = rows.get('UNP-DOUBLE');
+ expect(doubled?.value_flag, 'doubled annex flagged').toBe('annex_total_suspect');
+ const signingEur = Math.round(77_000_000 / 1.95583);
+ expect(doubled?.amount_eur, 'amount_eur falls back to signing').toBe(signingEur);
+ // Excluded from the current_value aggregate entirely.
+ expect(doubled?.current_value_eur, 'current_value_eur suppressed').toBeNull();
+
+ // (b) genuine +30% increase untouched: stays ok, value reflects current_value (130 BGN).
+ const small = rows.get('UNP-SMALL');
+ expect(small?.value_flag, 'small increase stays ok').toBe('ok');
+ expect(small?.amount_eur, 'small increase keeps current value').toBe(
+ Math.round(130 / 1.95583),
+ );
+ expect(small?.current_value_eur).toBe(Math.round(130 / 1.95583));
+ });
+ });
+
+ it(`${label}: an old doubled annex superseded by a correct later annex is NOT flagged`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // (d) An early annex doubled (before 100 → after 200), but a LATER annex sets a correct,
+ // sub-2x current_value (before 200 → after 130). The doubled step no longer DRIVES
+ // current_value, so the ABS(...-current_value) tie must leave the contract 'ok'.
+ {
+ unp: 'UNP-SUPERSEDED',
+ signing: 100,
+ steps: [
+ { before: 100, after: 200, publishedAt: '2026-06-10' },
+ { before: 200, after: 130, publishedAt: '2026-06-20' },
+ ],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const row = rowsByUnp(dbPath).get('UNP-SUPERSEDED');
+ expect(row?.value_flag, 'superseded double is not flagged').toBe('ok');
+ // Served at the corrected current value (130 BGN), not the transient doubled 200.
+ expect(row?.amount_eur).toBe(Math.round(130 / 1.95583));
+ expect(row?.current_value_eur).toBe(Math.round(130 / 1.95583));
+ });
+ });
+ }
+
+ it('full-vs-slice parity: the doubled contract gets the same value_flag on both paths', () => {
+ const cases: Case[] = [
+ {
+ unp: 'UNP-DOUBLE',
+ signing: 77_000_000,
+ steps: [{ before: 77_000_000, after: 154_000_000, publishedAt: '2026-06-10' }],
+ },
+ ];
+
+ let fullFlag: string | undefined;
+ withEtlDb('parity-full', (dbPath) => {
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of [derivePath, normalizePath, promotePath, precomputePath])
+ readScript(dbPath, p);
+ fullFlag = rowsByUnp(dbPath).get('UNP-DOUBLE')?.value_flag;
+ });
+
+ let sliceFlag: string | undefined;
+ withEtlDb('parity-slice', (dbPath) => {
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of [derivePath, refreshSlicePath, precomputePath]) readScript(dbPath, p);
+ sliceFlag = rowsByUnp(dbPath).get('UNP-DOUBLE')?.value_flag;
+ });
+
+ expect(fullFlag).toBe('annex_total_suspect');
+ expect(sliceFlag).toBe('annex_total_suspect');
+ expect(fullFlag).toBe(sliceFlag);
+ });
+});
+
+// #305 multi-annex residual: the doubled step is NOT always the first annex. When a later annex reports
+// a new TOTAL added to an already-grown value, its value_before is the prior CUMULATIVE total (a preceding
+// annex's value_after), not signing. The relaxed anchor flags these too; the ≥2× single-step gate (ЗОП
+// чл.116) keeps slow legitimate climbs — whose later steps never reach 2× — untouched.
+describe('#305 annex_total_suspect multi-annex value double-count', () => {
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: flags a later-in-chain double whose value_before ties to a prior annex total, not signing`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // Chain: 1M signed → annex1 +40% (1.4M, legal, not a double) → annex2 doubles the 1.4M total to
+ // 2.8M. The driving annex's value_before (1.4M) equals the PRIOR annex's value_after, not signing
+ // (1M), so the old signing-only anchor missed it. Must now flag and fall back to signing.
+ {
+ unp: 'UNP-MULTI-DOUBLE',
+ signing: 1_000_000,
+ steps: [
+ { before: 1_000_000, after: 1_400_000, publishedAt: '2026-06-10' },
+ { before: 1_400_000, after: 2_800_000, publishedAt: '2026-06-20' },
+ ],
+ },
+ // Control: same shape but the later step is a legal +36% (1.4M → 1.9M), below 2×. The relaxed
+ // anchor matches value_before to the prior total, but the ≥2× gate must keep this 'ok'.
+ {
+ unp: 'UNP-MULTI-OK',
+ signing: 1_000_000,
+ steps: [
+ { before: 1_000_000, after: 1_400_000, publishedAt: '2026-06-10' },
+ { before: 1_400_000, after: 1_900_000, publishedAt: '2026-06-20' },
+ ],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const rows = rowsByUnp(dbPath);
+
+ const multiDouble = rows.get('UNP-MULTI-DOUBLE');
+ expect(multiDouble?.value_flag, 'later-in-chain double flagged').toBe(
+ 'annex_total_suspect',
+ );
+ expect(multiDouble?.amount_eur, 'amount_eur falls back to signing').toBe(
+ Math.round(1_000_000 / 1.95583),
+ );
+ expect(multiDouble?.current_value_eur, 'current_value_eur suppressed').toBeNull();
+
+ const multiOk = rows.get('UNP-MULTI-OK');
+ expect(multiOk?.value_flag, 'legal <2x later step stays ok').toBe('ok');
+ expect(multiOk?.amount_eur, 'ok row keeps current value').toBe(
+ Math.round(1_900_000 / 1.95583),
+ );
+ expect(multiOk?.current_value_eur).toBe(Math.round(1_900_000 / 1.95583));
+ });
+ });
+ }
+
+ // The per-row value_suspect marker (served amendments) must also catch the later-in-chain double, so the
+ // UI blanks that specific annex row — not just the contract-level flag.
+ const MULTI_TREATED: TreatedCase = {
+ unp: 'UNP-MULTI-SUSPECT',
+ signing: 1_000_000,
+ steps: [
+ {
+ before: 1_000_000,
+ after: 1_400_000,
+ publishedAt: '2026-06-10',
+ treatment: null,
+ restatedAfter: null,
+ },
+ {
+ before: 1_400_000,
+ after: 2_800_000,
+ publishedAt: '2026-06-20',
+ treatment: null,
+ restatedAfter: null,
+ },
+ ],
+ };
+
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: marks value_suspect=1 on the later-in-chain doubled annex row, not the legal earlier one`, () => {
+ withEtlDb(label, (dbPath) => {
+ seedTreatedContracts(dbPath, [MULTI_TREATED]);
+ seedTreatedAmendments(dbPath, [MULTI_TREATED]);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const served = sqliteJson<{ value_after: number; value_suspect: number }>(
+ dbPath,
+ `SELECT value_after, value_suspect FROM amendments
+ WHERE unp = 'UNP-MULTI-SUSPECT' ORDER BY value_after`,
+ );
+ expect(served.length, 'both annex rows served').toBe(2);
+ // The legal +40% step (1.4M) is not suspect; the doubled step (2.8M) is.
+ const legal = served.find((r) => r.value_after === 1_400_000);
+ const doubled = served.find((r) => r.value_after === 2_800_000);
+ expect(legal?.value_suspect, 'legal earlier step not suspect').toBe(0);
+ expect(doubled?.value_suspect, 'later-in-chain double marked suspect').toBe(1);
+ });
+ });
+ }
+});
+
+// #305 NEW-HIGH-1 (multi-annex chain contamination): the double-count correction is per-row and does NOT
+// propagate down a chain. A restated prior annex (doubled → corrected down) leaves a LATER annex still
+// computed by the feed on the contaminated (raw, doubled) base. The later annex's own step ratio is
+// legitimate (<2×) so the arithmetic gate misses it and the prior is text-treated (excluded) — yet
+// current_value inherited the doubled total. The new branch flags it → signing fallback, on both paths.
+describe('#305 NEW-HIGH-1 multi-annex chain contamination', () => {
+ // annex1 doubled 1M→2.4M, text-restated to 1.4M; annex2 is a real +15% the feed computed on the RAW 2.4M
+ // base (2.4M→2.76M). annex2's own ratio is 1.15× so the gate misses it; annex1 is treated so it is
+ // excluded. Without the fix, current_value serves the contaminated 2.76M.
+ const CONTAM: TreatedCase = {
+ unp: 'UNP-CHAIN-CONTAM',
+ signing: 1_000_000,
+ steps: [
+ {
+ before: 1_000_000,
+ after: 2_400_000,
+ publishedAt: '2026-06-10',
+ treatment: 'total_restated',
+ restatedAfter: 1_400_000,
+ },
+ {
+ before: 2_400_000,
+ after: 2_760_000,
+ publishedAt: '2026-06-20',
+ treatment: null,
+ restatedAfter: null,
+ },
+ ],
+ };
+ // Control: the same shape on an HONEST base — annex1 is a genuine +40% (no restatement), annex2 +15% on
+ // the clean 1.4M base. No treated prior, so the contamination branch must NOT fire; stays 'ok'.
+ const CLEAN: TreatedCase = {
+ unp: 'UNP-CHAIN-CLEAN',
+ signing: 1_000_000,
+ steps: [
+ {
+ before: 1_000_000,
+ after: 1_400_000,
+ publishedAt: '2026-06-10',
+ treatment: null,
+ restatedAfter: null,
+ },
+ {
+ before: 1_400_000,
+ after: 1_610_000,
+ publishedAt: '2026-06-20',
+ treatment: null,
+ restatedAfter: null,
+ },
+ ],
+ };
+
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: flags a later annex riding a restated prior's doubled base, and keeps a clean chain ok`, () => {
+ withEtlDb(label, (dbPath) => {
+ seedTreatedContracts(dbPath, [CONTAM, CLEAN]);
+ seedTreatedAmendments(dbPath, [CONTAM, CLEAN]);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const rows = rowsByUnp(dbPath);
+
+ const contam = rows.get('UNP-CHAIN-CONTAM');
+ expect(contam?.value_flag, 'contaminated later annex flagged').toBe('annex_total_suspect');
+ expect(contam?.amount_eur, 'falls back to signing, not the contaminated 2.76M').toBe(
+ Math.round(1_000_000 / 1.95583),
+ );
+ expect(contam?.current_value_eur, 'contaminated current_value suppressed').toBeNull();
+
+ const clean = rows.get('UNP-CHAIN-CLEAN');
+ expect(clean?.value_flag, 'honest two-step growth stays ok').toBe('ok');
+ expect(clean?.current_value_eur).toBe(Math.round(1_610_000 / 1.95583));
+ });
+ });
+ }
+
+ it('full-vs-slice parity: the contaminated chain gets the same flag on both paths', () => {
+ let fullFlag: string | undefined;
+ withEtlDb('parity-full', (dbPath) => {
+ seedTreatedContracts(dbPath, [CONTAM]);
+ seedTreatedAmendments(dbPath, [CONTAM]);
+ for (const p of [derivePath, normalizePath, promotePath, precomputePath])
+ readScript(dbPath, p);
+ fullFlag = rowsByUnp(dbPath).get('UNP-CHAIN-CONTAM')?.value_flag;
+ });
+
+ let sliceFlag: string | undefined;
+ withEtlDb('parity-slice', (dbPath) => {
+ seedTreatedContracts(dbPath, [CONTAM]);
+ seedTreatedAmendments(dbPath, [CONTAM]);
+ for (const p of [derivePath, refreshSlicePath, precomputePath]) readScript(dbPath, p);
+ sliceFlag = rowsByUnp(dbPath).get('UNP-CHAIN-CONTAM')?.value_flag;
+ });
+
+ expect(fullFlag).toBe('annex_total_suspect');
+ expect(sliceFlag).toBe('annex_total_suspect');
+ });
+});
+
+// #305 84818-class: an EXACT single-step 2× (value_after ≈ 2× value_before) is the ЗОП чл.116 defect
+// signature even when value_before anchors to NEITHER signing NOR a prior annex total (an orphan base) —
+// as in real contract 84818, whose annex reports 76.77M → 153.54M on a base unrelated to the contract's
+// signing. The gate flags it → signing fallback (EXCLUDE), without ever rewriting the value.
+describe('#305 84818-class orphan exact-double', () => {
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: flags an exact 2× on an orphan base, but leaves a non-exact orphan jump ok`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // Orphan exact 2×: value_before 90 000 ties neither signing (195 583) nor any prior annex, and
+ // value_after is exactly 2×. Flag → signing fallback (100 000 EUR), never the doubled 180 000.
+ {
+ unp: 'UNP-ORPHAN-EXACT',
+ signing: 195_583,
+ steps: [{ before: 90_000, after: 180_000, publishedAt: '2026-06-10' }],
+ },
+ // Control: an orphan jump that is NOT an exact 2× (1.5×) is ambiguous — with no anchor and no
+ // exact-double signature it must stay ok (the relaxed rule is scoped to EXACT 2× only).
+ {
+ unp: 'UNP-ORPHAN-SMALL',
+ signing: 195_583,
+ steps: [{ before: 90_000, after: 135_000, publishedAt: '2026-06-10' }],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const rows = rowsByUnp(dbPath);
+
+ const orphan = rows.get('UNP-ORPHAN-EXACT');
+ expect(orphan?.value_flag, 'orphan exact 2× flagged').toBe('annex_total_suspect');
+ expect(orphan?.amount_eur, 'falls back to signing, not the doubled 180k').toBe(
+ Math.round(195_583 / 1.95583),
+ );
+ expect(orphan?.current_value_eur, 'doubled current suppressed').toBeNull();
+
+ const small = rows.get('UNP-ORPHAN-SMALL');
+ expect(small?.value_flag, 'non-exact orphan jump stays ok').toBe('ok');
+ expect(small?.current_value_eur).toBe(Math.round(135_000 / 1.95583));
+ });
+ });
+ }
+});
+
+// #305 NEW-HIGH-2 (reconciliation parity): the slice reconciliation reads the CUMULATIVE served
+// `amendments`, whose value_after is RESTATED, while the full path anchors on RAW values. A restated prior
+// annex used to flip the anchor's "prev not itself a double" test (restated 1.4M < 2×1.2M passes; the raw
+// 2.4M would fail), flagging on the slice but not on the full rebuild. The `prev.value_restated = 0` guard
+// restores parity: both paths reach the same verdict for a restated-prior + doubled-later chain.
+describe('#305 NEW-HIGH-2 slice reconciliation parity', () => {
+ // annex1 doubled 1.2M→2.4M, restated to 1.4M; annex2 grows the RESTATED 1.4M to 2.9M (a ≥2× step, but
+ // deliberately NOT an exact 2× so the 84818-class rule doesn't fire and mask the guard under test). On the
+ // full (raw) path annex2's value_before (1.4M) anchors to neither signing (1M) nor the raw prior total
+ // (2.4M) → 'ok'. Pre-guard the slice reconciliation matched the restated 1.4M and flagged — the flip.
+ // Post-guard (prev.value_restated = 0): 'ok', matching the full rebuild.
+ const FLIP: TreatedCase = {
+ unp: 'UNP-RECON-FLIP',
+ signing: 1_000_000,
+ steps: [
+ {
+ before: 1_200_000,
+ after: 2_400_000,
+ publishedAt: '2026-06-10',
+ treatment: 'total_restated',
+ restatedAfter: 1_400_000,
+ },
+ {
+ before: 1_400_000,
+ after: 2_900_000,
+ publishedAt: '2026-06-20',
+ treatment: null,
+ restatedAfter: null,
+ },
+ ],
+ };
+
+ it('full and slice agree on a restated-prior + doubled-later chain (no flag flip)', () => {
+ let fullFlag: string | undefined;
+ withEtlDb('parity-full', (dbPath) => {
+ seedTreatedContracts(dbPath, [FLIP]);
+ seedTreatedAmendments(dbPath, [FLIP]);
+ for (const p of [derivePath, normalizePath, promotePath, precomputePath])
+ readScript(dbPath, p);
+ fullFlag = rowsByUnp(dbPath).get('UNP-RECON-FLIP')?.value_flag;
+ });
+
+ let sliceFlag: string | undefined;
+ withEtlDb('parity-slice', (dbPath) => {
+ seedTreatedContracts(dbPath, [FLIP]);
+ seedTreatedAmendments(dbPath, [FLIP]);
+ for (const p of [derivePath, refreshSlicePath, precomputePath]) readScript(dbPath, p);
+ sliceFlag = rowsByUnp(dbPath).get('UNP-RECON-FLIP')?.value_flag;
+ });
+
+ // The canonical full-rebuild verdict, matched by the slice (pre-guard the slice flipped to suspect).
+ expect(sliceFlag).toBe(fullFlag);
+ });
+});
diff --git a/packages/db/src/contractor-identity-sql.test.ts b/packages/db/src/contractor-identity-sql.test.ts
index efd769fe..e0fda021 100644
--- a/packages/db/src/contractor-identity-sql.test.ts
+++ b/packages/db/src/contractor-identity-sql.test.ts
@@ -16,6 +16,26 @@ const migration3 = readFileSync(
resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql'),
'utf8',
);
+// …and 0009, which those same blocks now join for the Trade Register evidence gate (#279, ADR-0033).
+const migration9 = readFileSync(
+ resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql'),
+ 'utf8',
+);
+// #305 Tier-2: served amendments gained value_restated/value_treatment (promote + refresh-slice write them).
+const migration6 = readFileSync(
+ resolve(root, 'packages/db/migrations/0006_amendment_restated.sql'),
+ 'utf8',
+);
+// #305 residual: served amendments gained value_suspect (promote + refresh-slice write it).
+const migration7 = readFileSync(
+ resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql'),
+ 'utf8',
+);
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8 = readFileSync(
+ resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql'),
+ 'utf8',
+);
const staging = readFileSync(resolve(root, 'scripts/work-staging-schema.sql'), 'utf8');
const normalize = readFileSync(resolve(root, 'scripts/normalize-raw.sql'), 'utf8');
const precompute = readFileSync(resolve(root, 'scripts/precompute.sql'), 'utf8');
@@ -66,7 +86,10 @@ function build(path: 'normalize' | 'refresh'): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(schema);
db.exec(migration2);
- db.exec(migration3);
+ db.exec(migration3 + migration9);
+ db.exec(migration6);
+ db.exec(migration7);
+ db.exec(migration8);
db.exec(staging);
db.exec(seed);
if (path === 'normalize') {
diff --git a/packages/db/src/etl-entity-canonicalization-sql.test.ts b/packages/db/src/etl-entity-canonicalization-sql.test.ts
index df67b27e..574c8cec 100644
--- a/packages/db/src/etl-entity-canonicalization-sql.test.ts
+++ b/packages/db/src/etl-entity-canonicalization-sql.test.ts
@@ -11,6 +11,13 @@ const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
// refresh-slice.sql's officials block reads interest_links (0003); build it so the script doesn't fail.
const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// …and 0006, joined by the officials block for the Trade Register evidence gate (#279, ADR-0033).
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+// #305 Tier-2: served amendments gained value_restated/value_treatment (promote + refresh-slice write them).
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
const etlPaths = [
['normalize-raw', resolve(root, 'scripts/normalize-raw.sql')],
@@ -40,6 +47,10 @@ function withEtlDb(label: string, run: (dbPath: string) => void): void {
readScript(dbPath, schemaPath);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, stagingPath);
run(dbPath);
} finally {
diff --git a/packages/db/src/integrity-checks.test.ts b/packages/db/src/integrity-checks.test.ts
index 52acac25..f6d9c7ff 100644
--- a/packages/db/src/integrity-checks.test.ts
+++ b/packages/db/src/integrity-checks.test.ts
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
assertIntegrity,
+ checkAmendmentTwins,
checkCurrentAmountParity,
checkDateSanity,
checkEikValidity,
@@ -28,6 +29,8 @@ const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bid
const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
// precompute.sql's officials block reads interest_links (0003); build it so precompute doesn't fail.
const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// …and 0006, joined by the officials block for the Trade Register evidence gate (#279, ADR-0033).
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
const precomputePath = resolve(root, 'scripts/precompute.sql');
function sqlite(dbPath: string, sql: string): void {
@@ -71,6 +74,7 @@ function freshDb(): string {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
sqlite(dbPath, CLEAN_FIXTURE);
return dbPath;
}
@@ -117,6 +121,7 @@ describe('reconciliation gate — clean corpus', () => {
'eik-validity',
'date-sanity',
'staging-reconciliation',
+ 'amendment-twin-dedup',
])
expect(results.find((r) => r.name === nm)?.skipped, `${nm} must not skip`).toBe(false);
});
@@ -229,6 +234,100 @@ describe('reconciliation gate — injected violations', () => {
expect(result.detail).toMatch(/EMPTY corpus/);
});
+ // #286/#302: the OCDS→EOP bridge lets an OCDS amendment reach the same contract as its EOP twin, and
+ // the prefer-EOP dedup (a DELETE two scripts earlier) is the SOLE guard, since promotion into
+ // `amendments` is unconditional. This gate is that dedup's post-condition. The suite already pins the
+ // dedup behaviourally; these cases pin the GATE — without them an `ok: n === 0` → `ok: true` edit
+ // leaves every package green while the gate is inert.
+ //
+ // Sources are the real staged partition shapes: `eop:annexes:` spanning 2020-05-08..2026-08-11 on
+ // the live corpus, `ocds:` only 2026 (the OCDS feed is go-forward). The gate matches by source
+ // PREFIX, so the fixtures below use both ends of the real EOP range — a pattern pinned to one year
+ // drops an arm and blows the count. The OCDS side cannot be spread the same way without inventing a
+ // partition that does not exist, so an `ocds:2026%` narrowing stays out of reach of honest fixtures;
+ // it is latent-in-2027, not a present regression.
+ it('amendment-twin-dedup catches an EOP and an OCDS amendment on the same (unp, contract_number)', async () => {
+ const db = track(freshDb());
+ sqlite(
+ db,
+ `INSERT INTO amendments (id, natural_key, contract_number, unp, published_at, document_number, source)
+ VALUES
+ ('am:UNP-1:C-1:E1','am:UNP-1:C-1:E1','C-1','UNP-1','2026-03-05','E1','eop:annexes:2026-03-05'),
+ ('am:UNP-1:C-1:ocds-1','am:UNP-1:C-1:ocds-1','C-1','UNP-1','2026-03-05','ocds-e82gsb-1','ocds:2026-03-05');`,
+ );
+ const result = await checkAmendmentTwins(runner(db));
+ expect(result.ok).toBe(false);
+ expect(result.skipped).toBe(false);
+ // Anchor the COUNT (so `11` cannot satisfy `1`) but keep the prose match to the stable half of the
+ // message, not the whole sentence.
+ expect(result.detail).toMatch(/^1\b/);
+ expect(result.detail).toMatch(/prefer-EOP dedup regressed/);
+ });
+
+ it('amendment-twin-dedup stays green on the shapes a working dedup actually leaves behind', async () => {
+ const db = track(freshDb());
+ sqlite(
+ db,
+ `INSERT INTO amendments (id, natural_key, contract_number, unp, published_at, document_number, source)
+ VALUES
+ -- two EOP annexes on one contract: the normal case, not a twin
+ ('am:UNP-1:C-1:E1','am:UNP-1:C-1:E1','C-1','UNP-1','2022-03-01','E1','eop:annexes:2022-03-01'),
+ ('am:UNP-1:C-1:E2','am:UNP-1:C-1:E2','C-1','UNP-1','2022-06-01','E2','eop:annexes:2022-06-01'),
+ -- a genuinely OCDS-only annex on a DIFFERENT contract: what the dedup deliberately keeps
+ ('am:UNP-2:C-2:ocds-1','am:UNP-2:C-2:ocds-1','C-2','UNP-2','2026-02-03','ocds-e82gsb-1','ocds:2026-02-03'),
+ -- an OCDS row the bridge refused (still keyed by its OCID) next to an EOP annex on the same
+ -- contract number: an honest residual, NOT double counting, because every consumer keys on
+ -- (unp, contract_number) and this row's unp matches no contract
+ ('am:ocds-3:C-1:ocds-2','am:ocds-3:C-1:ocds-2','C-1','ocds-e82gsb-3','2026-04-07','ocds-e82gsb-2','ocds:2026-04-07'),
+ -- NULL contract_number on both sides: cannot roll onto a contract, so it cannot double count
+ ('am:UNP-3::E3','am:UNP-3::E3',NULL,'UNP-3','2026-05-06','E3','eop:annexes:2026-05-06'),
+ ('am:UNP-3::ocds-3','am:UNP-3::ocds-3',NULL,'UNP-3','2026-05-06','ocds-e82gsb-4','ocds:2026-05-06'),
+ -- NULL unp on both sides, same contract number: amendments.unp is nullable and both mappers
+ -- can leave it empty, but such a row joins no contract either, so it must stay green too
+ ('am:C-9:E4','am:C-9:E4','C-9',NULL,'2026-07-02','E4','eop:annexes:2026-07-02'),
+ ('am:C-9:ocds-5','am:C-9:ocds-5','C-9',NULL,'2026-07-02','ocds-e82gsb-5','ocds:2026-07-02');`,
+ );
+ const result = await checkAmendmentTwins(runner(db));
+ expect(result.ok).toBe(true);
+ expect(result.skipped).toBe(false);
+ expect(result.detail).toMatch(/prefer-EOP dedup intact/);
+ });
+
+ // THREE offending pairs that deliberately share a value along each axis — (U1,C1), (U1,C2), (U2,C1) —
+ // so the grouping key itself is pinned: collapsing it to `unp` alone counts 2, to `contract_number`
+ // alone counts 2, and only the real composite key counts 3. One pair also carries two EOP rows, so
+ // counting rows instead of pairs overshoots. The EOP sources sit at both ends of the real partition
+ // range (2020 and 2026), which is what kills a year-pinned pattern.
+ it('amendment-twin-dedup counts each offending pair once, keyed on BOTH columns', async () => {
+ const db = track(freshDb());
+ sqlite(
+ db,
+ `INSERT INTO amendments (id, natural_key, contract_number, unp, published_at, document_number, source)
+ VALUES
+ ('am:UNP-1:C-1:E1','am:UNP-1:C-1:E1','C-1','UNP-1','2020-05-08','E1','eop:annexes:2020-05-08'),
+ ('am:UNP-1:C-1:E2','am:UNP-1:C-1:E2','C-1','UNP-1','2026-08-11','E2','eop:annexes:2026-08-11'),
+ ('am:UNP-1:C-1:ocds-1','am:UNP-1:C-1:ocds-1','C-1','UNP-1','2026-01-04','ocds-e82gsb-1','ocds:2026-01-04'),
+ -- ...and TWO OCDS rows, which the dedup would drop together: a gate that demanded exactly one
+ -- row per side would walk straight past this pair
+ ('am:UNP-1:C-1:ocds-4','am:UNP-1:C-1:ocds-4','C-1','UNP-1','2026-06-15','ocds-e82gsb-4','ocds:2026-06-15'),
+ ('am:UNP-1:C-2:E3','am:UNP-1:C-2:E3','C-2','UNP-1','2023-09-12','E3','eop:annexes:2023-09-12'),
+ ('am:UNP-1:C-2:ocds-2','am:UNP-1:C-2:ocds-2','C-2','UNP-1','2026-05-20','ocds-e82gsb-2','ocds:2026-05-20'),
+ ('am:UNP-2:C-1:E4','am:UNP-2:C-1:E4','C-1','UNP-2','2026-08-11','E4','eop:annexes:2026-08-11'),
+ ('am:UNP-2:C-1:ocds-3','am:UNP-2:C-1:ocds-3','C-1','UNP-2','2026-08-11','ocds-e82gsb-3','ocds:2026-08-11');`,
+ );
+ const result = await checkAmendmentTwins(runner(db));
+ expect(result.ok).toBe(false);
+ expect(result.detail).toMatch(/^3\b/);
+ });
+
+ it('amendment-twin-dedup self-skips when the amendments table is absent (staging-only DB)', async () => {
+ const db = track(freshDb());
+ sqlite(db, 'DROP TABLE amendments;');
+ const result = await checkAmendmentTwins(runner(db));
+ expect(result.skipped).toBe(true);
+ expect(result.ok).toBe(true);
+ });
+
it('eik-validity catches eik_valid=1 with a non-numeric eik_normalized', async () => {
const db = track(freshDb());
sqlite(db, "UPDATE bidders SET eik_normalized = 'AB12' WHERE id = 'eik:131071587';");
diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts
index e5296aaa..a37050b2 100644
--- a/packages/db/src/migrations.test.ts
+++ b/packages/db/src/migrations.test.ts
@@ -11,6 +11,8 @@ const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration1 = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
const migration2 = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
const migration3 = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+const migration9 = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+const migration10 = resolve(root, 'packages/db/migrations/0010_publishing_gate_constraints.sql');
const backfill = resolve(root, 'scripts/backfill-current-value-currency.sql');
const precompute = resolve(root, 'scripts/precompute.sql');
@@ -131,6 +133,7 @@ describe('served migrations', () => {
);
readScript(dbPath, migration2);
readScript(dbPath, migration3);
+ readScript(dbPath, migration9);
readScript(dbPath, backfill);
readScript(dbPath, precompute);
@@ -150,4 +153,350 @@ describe('served migrations', () => {
rmSync(dir, { recursive: true, force: true });
}
});
+
+ // 0006 attaches the Trade Register evidence seal to a link (#279, ADR-0033). A SIDE TABLE rather than
+ // columns on interest_links, for two reasons that are easy to forget: SQLite's ADD COLUMN has no
+ // IF NOT EXISTS and migrations are applied by a bare `d1 execute --file` with no tracking, so a
+ // re-apply must be a no-op; and 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.
+ it('0006 adds the evidence seal and re-applying it is a no-op', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-migrations-0006-'));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ for (const m of [migration0, migration1, migration2, migration3, migration9])
+ readScript(dbPath, m);
+
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='interest_link_evidence';",
+ ).trim(),
+ ).toBe('1');
+
+ // A seal for a real link survives a second apply — the migration must not drop and recreate.
+ sqlite(
+ dbPath,
+ `INSERT INTO persons (id, name) VALUES ('person:a', 'Иван Петров Тестов');
+ INSERT INTO bidders (id, name, eik_normalized, eik_valid)
+ VALUES ('eik:201122335', 'АЛФА СТРОЙ ООД', '201122335', 1);
+ INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:k', 'k', 'person:a', 'eik:201122335', '201122335', 'АЛФА СТРОЙ ООД',
+ 'test', 'document', 'owns', 'private_ownership', 'published');
+ INSERT INTO interest_link_evidence
+ (link_key, evidence_kind, matched_fact, lookup_date, rules_version, live_status)
+ VALUES ('k', 'document', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live');`,
+ );
+ readScript(dbPath, migration9); // idempotent re-apply
+ expect(sqlite(dbPath, 'SELECT COUNT(*) FROM interest_link_evidence;').trim()).toBe('1');
+
+ // The FK is real: a seal for a link that does not exist is rejected. D1 enforces foreign keys,
+ // so this is what stops the ship path inserting seals before (or after wiping) their links.
+ expect(() =>
+ sqlite(
+ dbPath,
+ `PRAGMA foreign_keys=ON;
+ INSERT INTO interest_link_evidence
+ (link_key, evidence_kind, matched_fact, lookup_date, rules_version, live_status)
+ VALUES ('nope', 'document', 'eik', '2026-08-05', 'tr-rules-1', 'live');`,
+ ),
+ ).toThrow(/FOREIGN KEY/i);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ // #279 §2: the publishing gate reads these columns as enums, but the schema let them hold anything.
+ // A value that merely LOOKS like a gate value — the trailing space in 'published ' §2 names — passes
+ // every writer and then silently fails `status = 'published'` (or worse, passes a LIKE somewhere).
+ // A CHECK is the only place this can be enforced for every writer at once, including a hand-run UPDATE.
+ it('constrains the publishing-gate enums and the declaration identity key', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-migrations-'));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ for (const m of [migration0, migration3, migration9, migration10]) readScript(dbPath, m);
+
+ const seedLink = (status: string) =>
+ sqlite(
+ dbPath,
+ `INSERT INTO persons(id, name) VALUES ('person:a', 'А') ON CONFLICT DO NOTHING;
+ INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:${status}', '${status}', 'person:a', 'eik:201122335', '201122335',
+ 'АЛФА СТРОЙ ООД', 'test', 'document', 'owns', 'private_ownership', '${status}');`,
+ );
+
+ // §2's named case: 'published ' is not 'published'. It must be rejected, not stored.
+ expect(() => seedLink('published ')).toThrow(/CHECK/i);
+ expect(() => seedLink('publushed')).toThrow(/CHECK/i);
+ // POSITIVE CONTROL — every real status still inserts. A CHECK that rejected everything would pass
+ // the assertions above while breaking the loader.
+ for (const s of ['published', 'held', 'withdrawn', 'suppressed', 'internal'])
+ expect(() => seedLink(s)).not.toThrow();
+
+ // interest_class gates the surface just as hard: a non-surfaced class is what keeps a management
+ // role off the public page, so a typo'd class is a leak in the same way.
+ expect(() =>
+ sqlite(
+ dbPath,
+ `INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:c', 'c', 'person:a', 'eik:1', '1', 'X', 't', 'document', 'owns',
+ 'private_ownership ', 'held');`,
+ ),
+ ).toThrow(/CHECK/i);
+
+ // evidence_kind decides whether a link may be read at all (SURFACED_OWNERSHIP). ADR-0035's
+ // document_uncorroborated must be a legal value — and a misspelling must not be.
+ const seal = (kind: string) =>
+ sqlite(
+ dbPath,
+ `INSERT INTO interest_link_evidence
+ (link_key, evidence_kind, lookup_date, rules_version, live_status)
+ VALUES ('${kind}', '${kind}', '2026-08-12', 'tr-rules-1', 'live');`,
+ );
+ expect(() => seal('document_uncorroberated')).toThrow(/CHECK|FOREIGN KEY/i);
+ for (const k of ['document', 'confirmed', 'document_uncorroborated', 'refuted'])
+ expect(() => {
+ sqlite(
+ dbPath,
+ `INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:${k}', '${k}', 'person:a', 'eik:1', '1', 'X', 't', '${k}', 'owns',
+ 'private_ownership', 'held');
+ INSERT INTO interest_link_evidence
+ (link_key, evidence_kind, lookup_date, rules_version, live_status)
+ VALUES ('${k}', '${k}', '2026-08-12', 'tr-rules-1', 'live');`,
+ );
+ }).not.toThrow();
+
+ // §2: `UNIQUE (xml_file, control_hash)` did not constrain re-import. SQLite counts NULLs as
+ // DISTINCT and control_hash is genuinely optional at the source, so two hashless imports of the
+ // SAME declaration both inserted and double-counted the stakes it carries.
+ const decl = (id: string, hash: string | null, folder = '2023') =>
+ sqlite(
+ dbPath,
+ `INSERT INTO declarations
+ (id, person_id, xml_file, control_hash, folder_year, template, source_url)
+ VALUES ('${id}', 'person:a', 'A.xml', ${hash === null ? 'NULL' : `'${hash}'`},
+ '${folder}', 'assets', 'https://x/A.xml');`,
+ );
+ expect(() => decl('d:n1', null)).not.toThrow();
+ expect(() => decl('d:n2', null)).toThrow(/UNIQUE/i); // the case that used to slip through
+ expect(() => decl('d:1', 'H1')).not.toThrow();
+ expect(() => decl('d:2', 'H1')).toThrow(/UNIQUE/i);
+ // POSITIVE CONTROLS — the key must not over-constrain. The register reuses basenames across
+ // FOLDERS, so the same xml_file in a different folder is a DIFFERENT declaration (load.mjs
+ // namespaces its id for this reason), and a genuinely different hash is a corrected re-filing.
+ expect(() => decl('d:f2', null, '2024')).not.toThrow();
+ expect(() => decl('d:3', 'H2')).not.toThrow();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ // THE POINT OF 0007, stated as an executable claim rather than a comment (todorkolev, #309).
+ //
+ // 0003 is an ALREADY-APPLIED migration and is therefore never edited: `CREATE TABLE IF NOT EXISTS` is a
+ // no-op on a live database, so an in-place CHECK would exist only on freshly built ones. That would put
+ // two different schemas under one name — and the divergence would land precisely on the served database,
+ // which is where a hand-run `UPDATE status='published '` during an incident actually happens.
+ //
+ // So 0007 owns the enforcement for BOTH shapes, and this test is what keeps them one shape: a fresh
+ // 0000..0007 build and a legacy database retrofitted by 0007 must reject the SAME values and accept the
+ // SAME values. If either drifts, the parity assertions below fail rather than a reviewer noticing.
+ it('a fresh build and a retrofitted legacy database enforce the SAME gate', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-migrations-parity-'));
+ const fresh = resolve(dir, 'fresh.sqlite');
+ const legacy = resolve(dir, 'legacy.sqlite');
+ try {
+ // (1) fresh: the migration chain exactly as a new environment applies it.
+ for (const m of [migration0, migration3, migration9, migration10]) readScript(fresh, m);
+
+ // (2) legacy: 0003 + 0006 as they were BEFORE this PR, then 0007 retrofits. `interest_links` is
+ // recreated without constraints because that is the shape every already-deployed database holds.
+ for (const m of [migration0, migration3, migration9]) readScript(legacy, m);
+ sqlite(
+ legacy,
+ `DROP TABLE interest_link_evidence;
+ DROP TABLE interest_links;
+ CREATE TABLE interest_links (
+ id TEXT PRIMARY KEY, link_key TEXT NOT NULL UNIQUE,
+ person_id TEXT NOT NULL REFERENCES persons(id), bidder_id TEXT NOT NULL, eik TEXT NOT NULL,
+ entity_key TEXT NOT NULL, match_method TEXT, matcher_version TEXT NOT NULL,
+ publish_tier TEXT NOT NULL, relation TEXT NOT NULL,
+ interest_class TEXT NOT NULL DEFAULT 'management_role',
+ contemporaneous INTEGER NOT NULL DEFAULT 0, own_institution TEXT NOT NULL DEFAULT 'none',
+ evidence_count INTEGER NOT NULL DEFAULT 1, first_declared_year TEXT, last_declared_year TEXT,
+ contract_count INTEGER NOT NULL DEFAULT 0, contract_value_eur REAL, first_contract_year TEXT,
+ last_contract_year TEXT, status TEXT NOT NULL DEFAULT 'held', verified_by TEXT,
+ verified_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')));
+ CREATE TABLE interest_link_evidence (
+ link_key TEXT PRIMARY KEY REFERENCES interest_links(link_key), evidence_kind TEXT NOT NULL,
+ registry_role TEXT, matched_fact TEXT, entry_number TEXT, entry_date TEXT,
+ lookup_date TEXT NOT NULL, rules_version TEXT NOT NULL, live_status TEXT NOT NULL,
+ sealed_at TEXT NOT NULL DEFAULT (datetime('now')));`,
+ );
+ readScript(legacy, migration10);
+
+ const link = (db: string, id: string, status: string, cls = 'private_ownership') =>
+ sqlite(
+ db,
+ `INSERT INTO persons(id, name) VALUES ('person:a', 'А') ON CONFLICT DO NOTHING;
+ INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:${id}', '${id}', 'person:a', 'eik:1', '1', 'X', 't', 'document', 'owns',
+ '${cls}', '${status}');`,
+ );
+ const seal = (db: string, key: string, kind: string) =>
+ sqlite(
+ db,
+ `INSERT INTO interest_link_evidence
+ (link_key, evidence_kind, lookup_date, rules_version, live_status)
+ VALUES ('${key}', '${kind}', '2026-08-14', 'tr-rules-1', 'live');`,
+ );
+
+ for (const [label, db] of [
+ ['fresh', fresh],
+ ['legacy', legacy],
+ ] as const) {
+ // REJECTED identically — 'published ' is #279 §2's named case, and it is the one a human types.
+ expect(() => link(db, `bad1-${label}`, 'published ')).toThrow(/CHECK/i);
+ expect(() => link(db, `bad2-${label}`, 'publushed')).toThrow(/CHECK/i);
+ expect(() => link(db, `bad3-${label}`, 'held', 'private_ownership ')).toThrow(/CHECK/i);
+ // ACCEPTED identically — the gate is a bound, not a blanket, on both shapes.
+ expect(() => link(db, `ok-${label}`, 'published')).not.toThrow();
+ expect(() => seal(db, `ok-${label}`, 'document')).not.toThrow();
+ expect(() => seal(db, `ok-${label}`, 'document_uncorroborated')).toThrow(/UNIQUE|CHECK/i);
+ // …and the evidence enum is enforced on both too.
+ expect(() => link(db, `k2-${label}`, 'held')).not.toThrow();
+ expect(() => seal(db, `k2-${label}`, 'document_uncorroberated')).toThrow(/CHECK/i);
+ // The UPDATE path is the one an incident actually uses, and no CHECK would ever cover it on a
+ // legacy table — only the trigger does.
+ expect(() =>
+ sqlite(db, `UPDATE interest_links SET status='published ' WHERE link_key='ok-${label}';`),
+ ).toThrow(/CHECK/i);
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ // The retrofit has to be safe on a LIVE D1: ship-related-persons wipes rows, never table definitions,
+ // so a deployed database keeps its unconstrained 0003 shape until this migration rebuilds it.
+ it('0007 retrofits a pre-existing database, keeps its rows, and re-applies as a no-op', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-migrations-'));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ for (const m of [migration0, migration3, migration9]) readScript(dbPath, m);
+ // Simulate a database provisioned BEFORE today: 0003 now declares the constraints, so the legacy
+ // shape has to be recreated explicitly. This is the state every already-deployed environment is in
+ // — `CREATE TABLE IF NOT EXISTS` never revisited it and ship-related-persons only wipes rows.
+ sqlite(
+ dbPath,
+ `DROP TABLE declarations;
+ CREATE TABLE declarations (
+ id TEXT PRIMARY KEY, person_id TEXT NOT NULL REFERENCES persons(id), xml_file TEXT NOT NULL,
+ control_hash TEXT, folder_year TEXT NOT NULL, declared_year TEXT, template TEXT NOT NULL,
+ category TEXT, institution TEXT, position TEXT, source_url TEXT NOT NULL,
+ UNIQUE (xml_file, control_hash));
+ DROP TABLE interest_links;
+ CREATE TABLE interest_links (
+ id TEXT PRIMARY KEY, link_key TEXT NOT NULL UNIQUE,
+ person_id TEXT NOT NULL REFERENCES persons(id), bidder_id TEXT NOT NULL, eik TEXT NOT NULL,
+ entity_key TEXT NOT NULL, match_method TEXT, matcher_version TEXT NOT NULL,
+ publish_tier TEXT NOT NULL, relation TEXT NOT NULL,
+ interest_class TEXT NOT NULL DEFAULT 'management_role', contemporaneous INTEGER NOT NULL DEFAULT 0,
+ own_institution TEXT NOT NULL DEFAULT 'none', evidence_count INTEGER NOT NULL DEFAULT 1,
+ first_declared_year TEXT, last_declared_year TEXT, contract_count INTEGER NOT NULL DEFAULT 0,
+ contract_value_eur REAL, first_contract_year TEXT, last_contract_year TEXT,
+ status TEXT NOT NULL DEFAULT 'held', verified_by TEXT, verified_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')));`,
+ );
+ // The rows the retrofit must preserve — plus the two shapes it must not.
+ sqlite(
+ dbPath,
+ `INSERT INTO persons(id, name) VALUES ('person:a', 'А');
+ INSERT INTO declarations
+ (id, person_id, xml_file, control_hash, folder_year, template, source_url)
+ VALUES ('d:keep', 'person:a', 'K.xml', 'HK', '2023', 'assets', 'https://x/K.xml');
+ INSERT INTO declarations
+ (id, person_id, xml_file, control_hash, folder_year, template, source_url)
+ VALUES ('d:drop', 'person:a', 'D.xml', NULL, '2023', 'assets', 'https://x/D.xml');
+ INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:k', 'k', 'person:a', 'eik:1', '1', 'X', 't', 'document', 'owns',
+ 'private_ownership', 'published');
+ -- The uninterpretable status §2 is about, already stored because nothing rejected it.
+ INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:bad', 'bad', 'person:a', 'eik:1', '1', 'X', 't', 'document', 'owns',
+ 'private_ownership', 'published ');`,
+ );
+
+ readScript(dbPath, migration10);
+ // The valid rows survive — nothing is rebuilt, so nothing can be lost…
+ expect(sqlite(dbPath, "SELECT id FROM declarations WHERE id='d:keep';").trim()).toBe(
+ 'd:keep',
+ );
+ expect(sqlite(dbPath, "SELECT link_key FROM interest_links WHERE link_key='k';").trim()).toBe(
+ 'k',
+ );
+ // …including the pre-existing trailing-space row. A trigger constrains FUTURE writes; it cannot
+ // retroactively reject a row already stored, and deleting one would mean dropping a real link on a
+ // guess. The loader rewrites the table wholesale on the next run, which is what corrects it — and
+ // the read gate never showed it anyway, since 'published ' is not 'published'.
+ expect(sqlite(dbPath, 'SELECT COUNT(*) FROM interest_links;').trim()).toBe('2');
+ // The hashless row SURVIVES — it is a real declaration and NOT NULL was rejected for that reason —
+ // but it is now covered by the natural key, so re-importing it is refused rather than duplicated.
+ expect(sqlite(dbPath, "SELECT COUNT(*) FROM declarations WHERE id='d:drop';").trim()).toBe(
+ '1',
+ );
+ expect(() =>
+ sqlite(
+ dbPath,
+ `INSERT INTO declarations
+ (id, person_id, xml_file, control_hash, folder_year, template, source_url)
+ VALUES ('d:dup', 'person:a', 'D.xml', NULL, '2023', 'assets', 'https://x/D.xml');`,
+ ),
+ ).toThrow(/UNIQUE/i);
+ // …and the constraint is in force for every writer afterwards, on INSERT and on UPDATE alike.
+ // UPDATE is the one that matters most: a hand-run status change during an incident is exactly when
+ // a stray character gets typed, and it is the path no application-level validation covers.
+ expect(() =>
+ sqlite(dbPath, "UPDATE interest_links SET status='published ' WHERE link_key='k';"),
+ ).toThrow(/CHECK failed/i);
+ expect(() =>
+ sqlite(
+ dbPath,
+ `INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, matcher_version, publish_tier,
+ relation, interest_class, status)
+ VALUES ('il:x', 'x', 'person:a', 'eik:1', '1', 'X', 't', 'document', 'owns',
+ 'private_ownership', 'publushed');`,
+ ),
+ ).toThrow(/CHECK failed/i);
+ // POSITIVE CONTROL: a legitimate status change still goes through — the trigger is a bound.
+ expect(() =>
+ sqlite(dbPath, "UPDATE interest_links SET status='withdrawn' WHERE link_key='k';"),
+ ).not.toThrow();
+
+ // Migrations here are applied by a bare `d1 execute --file` with no applied-migrations tracking,
+ // so a second application MUST be a no-op rather than an error or a data loss.
+ readScript(dbPath, migration10);
+ expect(sqlite(dbPath, 'SELECT COUNT(*) FROM interest_links;').trim()).toBe('2');
+ expect(sqlite(dbPath, "SELECT id FROM declarations WHERE id='d:keep';").trim()).toBe(
+ 'd:keep',
+ );
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
});
diff --git a/packages/db/src/queries/details.test.ts b/packages/db/src/queries/details.test.ts
index 9371762f..014ffa8e 100644
--- a/packages/db/src/queries/details.test.ts
+++ b/packages/db/src/queries/details.test.ts
@@ -195,9 +195,35 @@ describe('getContract', () => {
expect(detail?.value.suspect).toBe(true);
expect(detail?.value.signingEur).toBe(256.49);
expect(detail?.value.currentEur).toBe(flag === 'annex_suspect' ? 1025.96 : 256.49);
+ expect(detail?.value.currentValueDoubled).toBe(false);
}
});
+ it('#307 blanks the current value for a KNOWN 2× double-count (annex_total_suspect)', async () => {
+ const detail = await getContract(
+ fakeDb(
+ {
+ ...baseContractRow,
+ signing_value: 256.49,
+ current_value: 512.98, // the doubled native figure — must NOT resurface
+ signing_value_eur: 256.49,
+ current_value_eur: null, // excluded from aggregates upstream
+ value_flag: 'annex_total_suspect',
+ },
+ [],
+ ),
+ 'c:1',
+ );
+
+ expect(detail?.value.suspect).toBe(true);
+ expect(detail?.value.currentValueDoubled).toBe(true);
+ // Blanked (—), never the doubled 512.98 nor a fabricated fallback.
+ expect(detail?.value.currentEur).toBeNull();
+ expect(detail?.value.deltaPct).toBeNull();
+ // The trustworthy signing value is still shown.
+ expect(detail?.value.signingEur).toBe(256.49);
+ });
+
// Exercises the real cohort path end-to-end (baseContractRow is clean-value, CPV '72', amount 5000).
// Guards the argument order into contractCohort: swapping value_flag ↔ division would make it return
// null and this would fail.
@@ -351,6 +377,38 @@ describe('getContract', () => {
});
});
+ it('#305 residual: suppresses value_after and delta for a suspect (uncorrectable double-count) annex', async () => {
+ const detail = await getContract(
+ fakeDb(
+ { ...baseContractRow, contract_number: 'C-6' },
+ [],
+ [
+ {
+ value_before: 1000,
+ value_after: 3000, // the source's untrusted doubled/tripled total
+ value_delta: 2000,
+ currency: 'EUR',
+ published_at: '2024-03-01',
+ document_number: 'A1',
+ description: 'Изменение на стойността',
+ value_restated: 0,
+ value_suspect: 1,
+ fx_rate: null,
+ },
+ ],
+ ),
+ 'c:1',
+ );
+
+ expect(detail?.amendments[0]).toMatchObject({
+ valueAfterEur: null, // suppressed — we don't stand behind the doubled figure
+ deltaEur: null,
+ suspect: true,
+ restated: false,
+ description: 'Изменение на стойността', // description still shown
+ });
+ });
+
it('converts foreign-currency amendments to EUR via the annex fx rate', async () => {
const detail = await getContract(
fakeDb(
diff --git a/packages/db/src/queries/details.ts b/packages/db/src/queries/details.ts
index 7a211d8d..9786347e 100644
--- a/packages/db/src/queries/details.ts
+++ b/packages/db/src/queries/details.ts
@@ -431,6 +431,8 @@ interface AmendmentRow {
published_at: string | null;
document_number: string | null;
description: string | null;
+ value_restated: number | null;
+ value_suspect: number | null;
fx_rate: number | null;
}
@@ -454,7 +456,8 @@ export const AMENDMENTS_SQL = `SELECT am.value_before, am.value_after, am.value_
WHERE f.base_currency = am.currency
AND f.rate_date <= am.published_at
AND f.rate_date >= date(am.published_at, '-10 days')
- ORDER BY f.rate_date DESC LIMIT 1) AS fx_rate
+ ORDER BY f.rate_date DESC LIMIT 1) AS fx_rate,
+ am.value_restated, am.value_suspect
FROM amendments am
WHERE am.unp = ? AND am.contract_number = ?
ORDER BY am.published_at, am.id`;
@@ -542,14 +545,20 @@ export async function getContract(
const suspect =
r.value_flag === 'value_suspect' ||
r.value_flag === 'annex_suspect' ||
+ r.value_flag === 'annex_total_suspect' ||
r.value_flag === 'review' ||
r.value_flag === 'value_low';
const dateSuspect = r.date_flag === 'signed_after_publication';
+ // #307 — annex_total_suspect is a KNOWN exact 2× double-count in current_value. Its current_value_eur is
+ // already NULL (excluded from aggregates), so the native fallback below would resurface the doubled figure
+ // under an "unverified" label. Blank it instead: a known-wrong number is worse than an honest gap.
+ const currentValueDoubled = r.value_flag === 'annex_total_suspect';
const signingEur =
r.signing_value_eur ?? eurFromNative(r.signing_value, r.contract_currency, r.fx_rate);
- const currentRaw =
- r.current_value_eur ??
- eurFromNative(r.current_value, r.current_value_currency || r.contract_currency, r.fx_rate);
+ const currentRaw = currentValueDoubled
+ ? null
+ : (r.current_value_eur ??
+ eurFromNative(r.current_value, r.current_value_currency || r.contract_currency, r.fx_rate));
const procedureEstimatedEur = eurFromNative(
r.estimated_value,
r.tender_currency,
@@ -606,12 +615,13 @@ export async function getContract(
estimatedEur: currentLotEstimatedEur ?? procedureEstimatedEur,
procedureEstimatedEur,
signingEur,
- currentEur: currentRaw ?? signingEur,
+ currentEur: currentValueDoubled ? null : (currentRaw ?? signingEur),
deltaPct:
!suspect && currentRaw != null && signingEur != null && signingEur !== 0
? (currentRaw - signingEur) / signingEur
: null,
suspect,
+ currentValueDoubled,
};
const authority: ContractParty = {
@@ -670,16 +680,28 @@ export async function getContract(
const amendments: ContractDetail['amendments'] = amendmentRows.results.map((am) => {
const beforeEur = eurFromNative(am.value_before, am.currency, am.fx_rate);
const afterEur = eurFromNative(am.value_after, am.currency, am.fx_rate);
+ // #305 residual: a suspected double-count we could NOT correct from the основание text. Its
+ // value_after is the untrusted doubled figure, so suppress it (and the derived delta) rather than
+ // show a number we can't stand behind — the UI marks the row „непотвърден тотал".
+ const suspect = am.value_suspect === 1;
return {
date: am.published_at,
documentNumber: am.document_number,
description: am.description?.trim() || null,
- valueAfterEur: afterEur,
+ valueAfterEur: suspect ? null : afterEur,
// Compute delta from the SAME before/after we display, so the row is self-consistent (after −
// before == delta) even when the source's recorded value_delta disagrees with them. When only
// one of before/after is known, the recorded delta can't be reconciled against valueAfterEur —
// show „—" rather than a figure that might not add up.
- deltaEur: beforeEur != null && afterEur != null ? afterEur - beforeEur : null,
+ deltaEur: suspect
+ ? null
+ : beforeEur != null && afterEur != null
+ ? afterEur - beforeEur
+ : null,
+ // #305 Tier-2: the served value_after was rewritten from the основание text (a double-count total
+ // restated to the true value) — let the UI mark the corrected row.
+ restated: am.value_restated === 1,
+ suspect,
};
});
diff --git a/packages/db/src/queries/related-persons.test.ts b/packages/db/src/queries/related-persons.test.ts
index 51985651..8ba09404 100644
--- a/packages/db/src/queries/related-persons.test.ts
+++ b/packages/db/src/queries/related-persons.test.ts
@@ -33,6 +33,13 @@ function row(over: Record = {}) {
first_contract_year: '2021',
last_contract_year: '2024',
source_url: 'https://register.cacbg.bg/2024/i.xml',
+ // SURFACED_OWNERSHIP only returns rows carrying a publishing seal, so the fixture carries one too —
+ // a fixture without it would exercise a row shape the SQL cannot produce.
+ evidence_kind: 'document',
+ registry_role: 'owner',
+ entry_number: '20110502101007',
+ entry_date: '2011-05-02',
+ lookup_date: '2026-08-05',
...over,
};
}
@@ -197,3 +204,47 @@ describe('conflict reads soft-fail on an un-migrated env (no 500)', () => {
}
});
});
+
+// The evidence seal is what licenses the card's registry sentence, and the two rungs assert very
+// different things: 'document' renders „лицето е вписано като съдружник/собственик" — that the register
+// names THIS person in THIS company — while 'confirmed' claims only that the declared data matched.
+// The mapper used to read `kind === 'confirmed' ? 'confirmed' : 'document'`, so NULL, 'refuted', a rung
+// added later, or a typo all became the STRONGEST claim we can make about a named human being. The SQL
+// gate makes that unreachable today; the failure DIRECTION is still wrong, and this is the one place in
+// the codebase where being wrong by default is libel rather than a rendering glitch.
+describe('an unrecognised evidence seal withholds the link instead of upgrading it', () => {
+ for (const kind of ['refuted', 'unknown', 'bar_joint_stock', 'outside_tr', 'future_rung_v9'])
+ it(`'${kind}' never renders as a registry claim`, async () => {
+ const db = fakeDb({ '10': [row({ evidence_kind: kind })] });
+ expect(await getConflictLeaderboard(db, 10)).toEqual([]);
+ });
+
+ it('a NULL seal — the half-migrated read the old comment invited — withholds too', async () => {
+ const db = fakeDb({ '10': [row({ evidence_kind: null })] });
+ expect(await getConflictLeaderboard(db, 10)).toEqual([]);
+ });
+
+ it('both publishing rungs still map, and to DIFFERENT kinds — the guard bounds, it does not flatten', async () => {
+ for (const kind of ['document', 'confirmed']) {
+ const db = fakeDb({ '10': [row({ evidence_kind: kind })] });
+ const links = await getConflictLeaderboard(db, 10);
+ expect(links).toHaveLength(1);
+ expect(links[0]!.evidenceKind).toBe(kind);
+ }
+ });
+
+ it('the official page 404s rather than render a page under a name with nothing left to show', async () => {
+ const db = fakeDb({ 'person:ivan': [row({ evidence_kind: 'refuted' })] });
+ expect(await getOfficialConflicts(db, 'person:ivan')).toBeNull();
+ expect(
+ await getCompanyConflicts(fakeDb({ '111': [row({ evidence_kind: null })] }), '111'),
+ ).toBeNull();
+ });
+
+ it('one withheld row does not take its sealed siblings down with it', async () => {
+ const db = fakeDb({
+ '10': [row({ link_key: 'ok|111' }), row({ link_key: 'bad|111', evidence_kind: 'refuted' })],
+ });
+ expect((await getConflictLeaderboard(db, 10)).map((l) => l.linkKey)).toEqual(['ok|111']);
+ });
+});
diff --git a/packages/db/src/queries/related-persons.ts b/packages/db/src/queries/related-persons.ts
index 05c3f228..25f2a752 100644
--- a/packages/db/src/queries/related-persons.ts
+++ b/packages/db/src/queries/related-persons.ts
@@ -19,6 +19,9 @@ const CONFLICT_TABLES = [
'declared_interests',
'interest_link_authorities',
'related_persons_internal',
+ // 0006 (#279, ADR-0033). Listed here for the same reason as the rest: on an environment where 0006
+ // has not been applied yet, the evidence join must degrade to an empty surface rather than a 500.
+ 'interest_link_evidence',
];
// „D1_ERROR: no such table: interest_links: SQLITE_ERROR" → capture the table name and test membership.
const MISSING_TABLE = /no such table:\s*(?:main\.)?"?([a-z_]+)"?/i;
@@ -79,11 +82,28 @@ interface LinkRow {
first_contract_year: string | null;
last_contract_year: string | null;
source_url: string | null;
+ evidence_kind: string | null;
+ registry_role: string | null;
+ entry_number: string | null;
+ entry_date: string | null;
+ lookup_date: string | null;
}
// The winner's contracts, joined exactly as the ETL aggregate does (contracts→tenders→authorities→bidders,
// matched by eik_normalized) so any read-time subset is a true subset of the stored contract_count/value.
// Alias-distinct (cc/tt/aa/bb) so it composes as a correlated subquery under the LINK_SELECT `il`/`b` scope.
+//
+// `tt` and `aa` are NOT projected here, which makes both joins look dead and invites deleting them. They
+// are not dead — they are the SHAPE, and its counterpart is the WRITER at scripts/cacbg/load.mjs (the
+// per-winner contract query that fills contract_count / contract_value_eur). The two must stay identical.
+//
+// Removing them on the read side ALONE would widen the read to contracts the stored aggregate never
+// counted: `contemporaneous_contract_count` could then exceed `contract_count`, and the EXISTS gate below
+// (the one that decides whether a link surfaces at all) would publish links the I5 zero-contract gate had
+// excluded. Removing them on BOTH sides is defensible — an unresolvable authority currently drops a
+// contract from the money everywhere, consistently — but it changes published figures and so must be
+// re-baselined against ADR-0033 §10's control totals, not slipped in as a cleanup. Tracked as #226 §1.6.
+// `related-persons-sql.test.ts` pins this string to the writer's so neither can be "optimised" alone.
const CONTRACT_JOIN = `FROM contracts cc
JOIN tenders tt ON tt.id = cc.tender_id
JOIN authorities aa ON aa.id = tt.authority_id
@@ -116,8 +136,15 @@ export const NEXUS_ORDER = `(il.own_institution = 'exact') DESC, (contemporaneou
// The two surfaced ownership classes (ADR-0032): the official's own stake (private_ownership) and a close
// relative's (family_ownership). Anchored once here + in LINK_CONTRACTS_SQL so the read gate and the
// drill-down never drift.
+// …and the identity must rest on a Trade Register fact (#279, ADR-0033). `status='published'` already
+// encodes the loader's decision, so this EXISTS is belt-and-braces: it makes the read path refuse a link
+// whose seal is missing or whose evidence is a withholding rung, even if a future writer sets status
+// wrongly. 'document' and 'confirmed' are the only two rungs that publish; bar_joint_stock, unknown,
+// refuted and outside_tr never reach a reader.
export const SURFACED_OWNERSHIP = `il.status = 'published'
- AND il.interest_class IN ('private_ownership', 'family_ownership')`;
+ AND il.interest_class IN ('private_ownership', 'family_ownership')
+ AND EXISTS (SELECT 1 FROM interest_link_evidence e
+ WHERE e.link_key = il.link_key AND e.evidence_kind IN ('document','confirmed'))`;
// Redundant-family collapse (ADR-0032, per todorkolev review). A family link is DROPPED when the SAME official
// already has a published OWN stake in the SAME winner. Rendering both a self row and a family row for one
// (official, ЕИК) is a de-anonymisation vector: the office-holder is himself in that company's Търговски
@@ -156,8 +183,13 @@ export const LINK_SELECT = `SELECT il.link_key, il.person_id, p.name AS official
-- (name, institution), ADR-0026; same subquery the search projection uses). Correlated per row, but the
-- leaderboard is ≤1000 rows and hourly-cached, so the extra scan is immaterial.
(SELECT d.institution FROM declarations d WHERE d.person_id = il.person_id
- ORDER BY d.declared_year DESC LIMIT 1) AS institution
+ ORDER BY d.declared_year DESC LIMIT 1) AS institution,
+ -- The evidence the link rests on, so the card can explain itself (ADR-0033 decision 7). LEFT JOIN
+ -- rather than an inner one: SURFACED_OWNERSHIP already requires a publishing seal, and an inner join
+ -- here would silently re-filter rather than surface a contradiction.
+ ev.evidence_kind, ev.registry_role, ev.entry_number, ev.entry_date, ev.lookup_date
FROM interest_links il
+ LEFT JOIN interest_link_evidence ev ON ev.link_key = il.link_key
JOIN persons p ON p.id = il.person_id
JOIN bidders b ON b.id = il.bidder_id
WHERE ${SURFACED_OWNERSHIP}
@@ -170,6 +202,25 @@ export const LINK_SELECT = `SELECT il.link_key, il.person_id, p.name AS official
-- …and drop a family link redundant with the official's own stake in the same winner (de-anon guard).
AND ${NOT_REDUNDANT_FAMILY}`;
+// The two rungs that license a public claim (ADR-0033 decision 1), and the ONLY two the card knows how
+// to render. Anything else — 'refuted', 'unknown', 'bar_joint_stock', 'outside_tr', a rung added by a
+// later rules_version, a typo, or NULL from a row with no seal — is withheld, never mapped.
+//
+// The mapper used to read `kind === 'confirmed' ? 'confirmed' : 'document'`, which turned every one of
+// those into 'document' — the STRONGEST claim on the surface, rendering „лицето е вписано като
+// съдружник/собственик": that the register names this specific person in this specific company. The SQL
+// gate makes it unreachable today, but the direction was wrong, and this is the one mapping in the
+// codebase where a default is a defamatory statement about a named human being rather than a glitch.
+// The LEFT JOIN in LINK_SELECT is deliberately not an inner one so a contradiction SURFACES here; the
+// old fallback then converted exactly that contradiction into the strongest possible label.
+const PUBLISHING_EVIDENCE = new Set(['document', 'confirmed']);
+
+/** Rows whose seal licenses a public claim. Withholding is silent by design — the SQL already filters
+ * these out, so anything reaching here is a contradiction to drop, not a condition to report per row. */
+function sealed(rows: LinkRow[]): LinkRow[] {
+ return rows.filter((r) => PUBLISHING_EVIDENCE.has(String(r.evidence_kind)));
+}
+
// own_institution is a 4-value verdict; only the deterministic 'exact' surfaces as true (the
// name_contains/locality heuristics are disclosed elsewhere, never asserted as fact).
function toLink(r: LinkRow): ConflictLink {
@@ -193,6 +244,18 @@ function toLink(r: LinkRow): ConflictLink {
firstContractYear: r.first_contract_year,
lastContractYear: r.last_contract_year,
sourceUrl: r.source_url,
+ // Narrowed, not defaulted — `sealed()` above has already dropped every other value, so this asserts
+ // what the filter guarantees instead of inventing a rung the row never carried.
+ evidenceKind: r.evidence_kind as 'document' | 'confirmed',
+ registryRole:
+ r.registry_role === 'owner' || r.registry_role === 'manager' ? r.registry_role : null,
+ registryEntryNumber: r.entry_number,
+ registryEntryDate: r.entry_date,
+ // Narrowed for the same reason as evidenceKind, not defaulted. `lookup_date` is NOT NULL in
+ // migration 0006 and the row only reaches here through the seal filter, so `?? ''` was a branch
+ // that could not run — and if the invariant ever broke it would have shipped an empty string as a
+ // date, which reads as a valid-but-blank provenance rather than as the contradiction it is.
+ registryLookupDate: r.lookup_date as string,
};
}
@@ -203,7 +266,7 @@ export const LEADERBOARD_SQL = `${LINK_SELECT}
* relative's) in a procurement winner, ranked NEXUS-first (own-institution → contemporaneous → value). */
export async function getConflictLeaderboard(db: D1Database, limit = 100): Promise {
try {
- const rows = (await db.prepare(LEADERBOARD_SQL).bind(limit).all()).results;
+ const rows = sealed((await db.prepare(LEADERBOARD_SQL).bind(limit).all()).results);
return rows.map(toLink);
} catch (e) {
if (conflictSchemaAbsent(e, 'leaderboard')) return []; // un-migrated env → empty surface, not a 500
@@ -221,7 +284,9 @@ export async function getOfficialConflicts(
personId: string,
): Promise {
try {
- const rows = (await db.prepare(OFFICIAL_SQL).bind(personId).all()).results;
+ // Filtered BEFORE the emptiness check, so a person whose every link is withheld 404s rather than
+ // rendering an empty page under their name.
+ const rows = sealed((await db.prepare(OFFICIAL_SQL).bind(personId).all()).results);
if (rows.length === 0) return null;
const links = rows.map(toLink);
return { official: links[0]!.official, links };
@@ -240,7 +305,7 @@ export async function getCompanyConflicts(
eik: string,
): Promise {
try {
- const rows = (await db.prepare(COMPANY_SQL).bind(eik).all()).results;
+ const rows = sealed((await db.prepare(COMPANY_SQL).bind(eik).all()).results);
if (rows.length === 0) return null;
return { company: rows[0]!.company, eik, links: rows.map(toLink) };
} catch (e) {
diff --git a/packages/db/src/queries/search.ts b/packages/db/src/queries/search.ts
index 7a19f01e..c3f3f02d 100644
--- a/packages/db/src/queries/search.ts
+++ b/packages/db/src/queries/search.ts
@@ -112,15 +112,23 @@ interface HitRow {
// One group's ranked hits. Company rows additionally carry a свързани-лица flag: a LEFT JOIN against the
// published conflict links keyed on the winner's ЕИК (= the company row's `ident`). Published, self OR family
-// stake, AND the winner must have LIVE contracts (the same read-time N9 gate LINK_SELECT applies), so search
-// flags exactly the companies the /conflicts surface shows and never one whose page would 404. A family-only
-// winner badges — the /conflicts page already publishes that link by name, so the badge discloses nothing the
-// surface doesn't. Binds: kind, match, limit.
+// stake, backed by a Trade Register evidence SEAL, AND the winner must have LIVE contracts (the same
+// read-time N9 gate LINK_SELECT applies), so search flags exactly the companies the /conflicts surface shows
+// and never one whose page would 404. A family-only winner badges — the /conflicts page already publishes
+// that link by name, so the badge discloses nothing the surface doesn't. Binds: kind, match, limit.
+//
+// The seal EXISTS clause is the same one SURFACED_OWNERSHIP applies (related-persons.ts), and it belongs
+// here for a sharper reason than symmetry: search is the WIDER surface. A published row whose seal is
+// missing or withholding — a legacy row, a partial run, a loader bug — would badge a свързани-лица claim
+// about a named official to every searcher, while the page behind the badge correctly showed nothing.
+// Four copies of this predicate now exist (here, SURFACED_OWNERSHIP, precompute.sql, refresh-slice.sql);
+// related-persons-sql.test.ts pins them to each other.
// Exported so search-sql.test runs the EXACT SQL (not a copy).
-// Built with or without the interest_links conflict join. The join is on the свързани-лица migration (0003);
-// on an env where it has not been applied yet the join would make the ENTIRE search 500 with „no such table:
-// interest_links". search() detects the table once per request and picks the no-conflict variant when absent,
-// so search degrades to has_conflict=0 rather than breaking (ADR-0031 robustness ask).
+// Built with or without the conflict join. The join reads BOTH свързани-лица migrations — interest_links
+// (0003) and interest_link_evidence (0006) — and on an env where either is unapplied it would make the
+// ENTIRE search 500 with „no such table". search() detects both tables once per request and picks the
+// no-conflict variant when either is absent, so search degrades to has_conflict=0 rather than breaking
+// (ADR-0031 robustness ask).
const hitsSql = (withConflict: boolean): string => `SELECT search_index.ref, search_index.title,
search_index.ident, search_index.subtitle, search_index.amount, rank,
ct.kind AS entity_kind, ct.ownership_kind, ct.eik_valid,
@@ -133,6 +141,8 @@ const hitsSql = (withConflict: boolean): string => `SELECT search_index.ref, sea
? `LEFT JOIN (
SELECT DISTINCT il.eik FROM interest_links il
WHERE il.status = 'published' AND il.interest_class IN ('private_ownership', 'family_ownership')
+ AND EXISTS (SELECT 1 FROM interest_link_evidence e
+ WHERE e.link_key = il.link_key AND e.evidence_kind IN ('document','confirmed'))
AND EXISTS (SELECT 1 FROM contracts cc JOIN bidders bb ON bb.id = cc.bidder_id
WHERE bb.eik_normalized = il.eik)
) cf ON search_index.kind = 'company' AND cf.eik = search_index.ident`
@@ -161,12 +171,21 @@ export async function search(db: D1Database, rawQuery: string): Promise();
const counts = new Map(countRows.results.map((r) => [r.kind, r.n]));
- // Pick the hits SQL once: on an env where the свързани-лица migration (0003) is not applied, the conflict
- // join would 500 the whole search — fall back to the no-conflict variant (has_conflict=0) instead.
+ // Pick the hits SQL once: on an env where the свързани-лица migrations are not applied, the conflict join
+ // would 500 the whole search — fall back to the no-conflict variant (has_conflict=0) instead.
+ //
+ // BOTH tables are required, not just interest_links: the join now also reads interest_link_evidence
+ // (0006), so an env with 0003 but not 0006 would break every search on every kind. Requiring both also
+ // gives the right answer on such an env — with no seal table nothing is provably sealed, so nothing
+ // should badge.
const hasConflictTable =
- (await db
- .prepare("SELECT 1 AS n FROM sqlite_master WHERE type='table' AND name='interest_links'")
- .first<{ n: number }>()) != null;
+ (
+ await db
+ .prepare(
+ "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name IN ('interest_links','interest_link_evidence')",
+ )
+ .first<{ n: number }>()
+ )?.n === 2;
const hitsSql = hasConflictTable ? SEARCH_HITS_SQL : SEARCH_HITS_SQL_NO_CONFLICT;
const built = await Promise.all(
diff --git a/packages/db/src/refresh-slice.test.ts b/packages/db/src/refresh-slice.test.ts
index 261a2874..3415a3b6 100644
--- a/packages/db/src/refresh-slice.test.ts
+++ b/packages/db/src/refresh-slice.test.ts
@@ -13,6 +13,14 @@ const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bid
const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
// refresh-slice.sql / precompute.sql officials block reads interest_links (0003) — build it in every chain.
const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// …and 0006, joined by the officials block for the Trade Register evidence gate (#279, ADR-0033).
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+// #305 Tier-2: served amendments gained value_restated/value_treatment (refresh-slice promotes them).
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+// #305 residual: served amendments gained value_suspect (refresh-slice promotes it).
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
const refreshSlicePath = resolve(root, 'scripts/refresh-slice.sql');
const normalizePath = resolve(root, 'scripts/normalize-raw.sql');
const deriveAmendmentsPath = resolve(root, 'scripts/derive-amendments.sql');
@@ -185,6 +193,10 @@ function initWorkDb(dbPath: string): void {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, workStagingSchemaPath);
}
@@ -576,6 +588,10 @@ describe('refresh-slice EOP base derivation', () => {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, workStagingSchemaPath);
seedEopBaseDay(dbPath);
@@ -650,6 +666,195 @@ describe('refresh-slice EOP base derivation', () => {
}
});
+ it('bridges an OCDS-only annex to its УНП on the slice path (issue #286)', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-ocds-'));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ readScript(dbPath, schemaPath);
+ readScript(dbPath, migration1Path);
+ readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
+ // 0006 too: refresh-slice.sql's свързани-лица block reads interest_link_evidence (#279), so the
+ // script cannot parse against a DB that stops at 0003 — every site here applies both.
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
+ readScript(dbPath, workStagingSchemaPath);
+
+ // An EOP procedure (tender + base contract) with УНП UNP-SLICE / tender.id TENDER-SLICE. An
+ // OCDS-only annex arrives keyed by the OCID, carrying tender_ext_id = TENDER-SLICE and NO EOP
+ // twin. The slice path's bridge must recover UNP-SLICE so the annex links to the served contract
+ // instead of staying a dead OCID row — the #286 fix, exercised through refresh-slice.sql.
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, dataset_year, fetched_at, unp, tender_id, procedure_type, procurement_subject,
+ cpv_code, cpv_description, contract_kind, estimated_value, currency, authority_name,
+ authority_eik, authority_type, published_at)
+ VALUES
+ ('eop:tenders:2026-06-01', 2026, '2026-06-07T00:00:00Z', 'UNP-SLICE', 'TENDER-SLICE',
+ 'open', 'Slice tender', '45000000', 'Construction', 'works', 5000, 'BGN',
+ 'Authority Slice', '733456781', 'public', '2026-06-01');
+
+ INSERT INTO raw_contracts
+ (source, dataset_year, dataset_variant, fetched_at, needs_enrichment, document_number,
+ published_at, unp, tender_ext_id, procedure_type, procurement_subject, cpv_code,
+ cpv_description, contract_kind, estimated_value, procurement_currency, legal_basis,
+ award_criteria, authority_name, authority_eik, authority_type, main_activity, notice_type,
+ lot_id, contract_number, contract_date, signing_value, currency, contract_subject,
+ awarded_to_group, contractor_eik, contractor_name, contractor_country, winner_size,
+ eu_funded, bids_received, bids_sme, bids_rejected, bids_non_eea, duration_days)
+ VALUES
+ ('eop:contracts:2026-06-01', 2026, 'eop', '2026-06-07T00:00:00Z', 0, 'DOC-SLICE',
+ '2026-06-01', 'UNP-SLICE', 'TENDER-SLICE', 'open', 'Slice tender', '45000000',
+ 'Construction', 'works', 5000, 'BGN', 'basis', 'lowest', 'Authority Slice', '733456781',
+ 'public', 'activity', 'notice', NULL, 'CONTRACT-SLICE', '2026-06-02', 1000, 'BGN',
+ 'Slice contract', 0, '787777778', 'Bidder Slice', 'BG', 'small', 0, 1, 1, 0, 0, 30);
+
+ INSERT INTO raw_amendments
+ (source, dataset_year, dataset_variant, fetched_at, seq_no, document_number,
+ contract_number, contract_date, published_at, unp, tender_ext_id, authority_eik,
+ authority_name, procurement_subject, contract_kind, value_before, value_after, value_delta,
+ currency, description)
+ VALUES
+ ('ocds:2026-06-02', 2026, 'ocds', '2026-06-08T00:00:00Z', '1', 'AMD-SLICE-O',
+ 'CONTRACT-SLICE', '2026-06-02', '2026-06-03', 'ocds-e82gsb-555', 'TENDER-SLICE',
+ '733456781', 'Authority Slice', 'Slice tender', 'works', 1000, NULL, NULL, 'BGN',
+ 'OCDS-only annex');`,
+ );
+
+ readScript(dbPath, refreshSlicePath);
+
+ // No served amendment keeps an OCID — the OCDS-only annex bridged to the real УНП.
+ expect(
+ sqliteJson<{ n: number }>(
+ dbPath,
+ "SELECT COUNT(*) AS n FROM amendments WHERE unp LIKE 'ocds-%'",
+ )[0]?.n,
+ ).toBe(0);
+
+ // The annex is served against CONTRACT-SLICE, keyed by the recovered УНП (not the OCID).
+ expect(
+ sqliteJson<{ unp: string; source: string }>(
+ dbPath,
+ `SELECT unp, CASE WHEN source LIKE 'ocds:%' THEN 'ocds' ELSE 'eop' END AS source
+ FROM amendments WHERE contract_number = 'CONTRACT-SLICE'`,
+ ),
+ ).toEqual([{ unp: 'UNP-SLICE', source: 'ocds' }]);
+
+ // …and it shows on the contract (annex_count = 1); current_value stays NULL — OCDS never sets
+ // an after-value, so nothing is fabricated.
+ expect(
+ sqliteJson<{ annex_count: number; current_value: number | null }>(
+ dbPath,
+ "SELECT annex_count, current_value FROM contracts WHERE contract_number = 'CONTRACT-SLICE'",
+ )[0],
+ ).toEqual({ annex_count: 1, current_value: null });
+
+ expect(sqlite(dbPath, 'PRAGMA foreign_key_check;').trim()).toBe('');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it('reconciles OCDS twins against the cumulative served amendments across windows (#286, HIGH 1)', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-xwindow-'));
+ try {
+ // A base EOP procedure (tender + contract) with УНП UNP-XW / tender.id TXW. `source` varies per
+ // window so a later slice re-reads the same contract (same derived id → INSERT OR REPLACE, no dup).
+ const base = (source: string): string =>
+ `INSERT INTO raw_tenders
+ (source, dataset_year, fetched_at, unp, tender_id, procedure_type, procurement_subject,
+ cpv_code, cpv_description, contract_kind, estimated_value, currency, authority_name,
+ authority_eik, authority_type, published_at)
+ VALUES
+ ('eop:tenders:${source}', 2026, '2026-06-09T00:00:00Z', 'UNP-XW', 'TXW', 'open',
+ 'Cross-window tender', '45000000', 'Construction', 'works', 5000, 'BGN',
+ 'Authority XW', '833456781', 'public', '2026-06-01');
+ INSERT INTO raw_contracts
+ (source, dataset_year, dataset_variant, fetched_at, needs_enrichment, document_number,
+ published_at, unp, tender_ext_id, procedure_type, procurement_subject, cpv_code,
+ cpv_description, contract_kind, estimated_value, procurement_currency, legal_basis,
+ award_criteria, authority_name, authority_eik, authority_type, main_activity, notice_type,
+ lot_id, contract_number, contract_date, signing_value, currency, contract_subject,
+ awarded_to_group, contractor_eik, contractor_name, contractor_country, winner_size,
+ eu_funded, bids_received, bids_sme, bids_rejected, bids_non_eea, duration_days)
+ VALUES
+ ('eop:contracts:${source}', 2026, 'eop', '2026-06-09T00:00:00Z', 0, 'DOC-XW',
+ '2026-06-01', 'UNP-XW', 'TXW', 'open', 'Cross-window tender', '45000000',
+ 'Construction', 'works', 5000, 'BGN', 'basis', 'lowest', 'Authority XW', '833456781',
+ 'public', 'activity', 'notice', NULL, 'CONTRACT-XW', '2026-06-02', 1000, 'BGN',
+ 'Cross-window contract', 0, '887777778', 'Bidder XW', 'BG', 'small', 0, 1, 1, 0, 0, 30);`;
+
+ const eopAnnex = `INSERT INTO raw_amendments
+ (source, dataset_year, dataset_variant, fetched_at, seq_no, document_number,
+ contract_number, contract_date, published_at, unp, authority_eik, authority_name,
+ procurement_subject, contract_kind, value_before, value_after, value_delta, currency, description)
+ VALUES
+ ('eop:annexes:2026-06-01', 2026, 'eop', '2026-06-09T00:00:00Z', '1', 'AMD-XW-E',
+ 'CONTRACT-XW', '2026-06-02', '2026-06-03', 'UNP-XW', '833456781', 'Authority XW',
+ 'Cross-window tender', 'works', 1000, 1500, 500, 'BGN', 'EOP annex');`;
+
+ const ocdsTwin = (source: string): string =>
+ `INSERT INTO raw_amendments
+ (source, dataset_year, dataset_variant, fetched_at, seq_no, document_number,
+ contract_number, contract_date, published_at, unp, tender_ext_id, authority_eik,
+ authority_name, procurement_subject, contract_kind, value_before, value_after, value_delta,
+ currency, description)
+ VALUES
+ ('ocds:${source}', 2026, 'ocds', '2026-06-09T00:00:00Z', '1', 'AMD-XW-O',
+ 'CONTRACT-XW', '2026-06-02', '2026-06-03', 'ocds-e82gsb-321', 'TXW', '833456781',
+ 'Authority XW', 'Cross-window tender', 'works', 1000, NULL, NULL, 'BGN', 'OCDS twin');`;
+
+ const servedRows = (dbPath: string) =>
+ sqliteJson<{ unp: string; source: string }>(
+ dbPath,
+ `SELECT unp, CASE WHEN source LIKE 'ocds:%' THEN 'ocds' ELSE 'eop' END AS source
+ FROM amendments WHERE contract_number = 'CONTRACT-XW'`,
+ );
+ const rollup = (dbPath: string) =>
+ sqliteJson<{ annex_count: number; current_value: number | null }>(
+ dbPath,
+ "SELECT annex_count, current_value FROM contracts WHERE contract_number = 'CONTRACT-XW'",
+ )[0];
+
+ // Direction A — EOP annex served first, OCDS twin arrives in a LATER window (the EOP annex is not
+ // in that window's raw_amendments). Pre-fix, the twin bridged, survived the raw-only DELETE, and
+ // promoted into the cumulative served table → annex_count = 2. The served-table check must drop it.
+ const dbA = resolve(dir, 'a.sqlite');
+ initWorkDb(dbA);
+ sqlite(dbA, `${base('2026-06-01')}\n${eopAnnex}`);
+ readScript(dbA, refreshSlicePath);
+ expect(servedRows(dbA)).toEqual([{ unp: 'UNP-XW', source: 'eop' }]);
+
+ resetRawStaging(dbA);
+ sqlite(dbA, `${base('2026-06-05')}\n${ocdsTwin('2026-06-05')}`);
+ readScript(dbA, refreshSlicePath);
+ expect(servedRows(dbA)).toEqual([{ unp: 'UNP-XW', source: 'eop' }]); // NOT doubled
+ expect(rollup(dbA)).toEqual({ annex_count: 1, current_value: 1500 });
+ expect(sqlite(dbA, 'PRAGMA foreign_key_check;').trim()).toBe('');
+
+ // Direction B — OCDS-only annex served first (net-new, no EOP twin yet), then the EOP annex arrives
+ // in a later window. The convergence DELETE must drop the stale served OCDS row so promotion of the
+ // EOP annex leaves exactly one served row (not two).
+ const dbB = resolve(dir, 'b.sqlite');
+ initWorkDb(dbB);
+ sqlite(dbB, `${base('2026-06-01')}\n${ocdsTwin('2026-06-01')}`);
+ readScript(dbB, refreshSlicePath);
+ expect(servedRows(dbB)).toEqual([{ unp: 'UNP-XW', source: 'ocds' }]);
+
+ resetRawStaging(dbB);
+ sqlite(dbB, `${base('2026-06-05')}\n${eopAnnex}`);
+ readScript(dbB, refreshSlicePath);
+ expect(servedRows(dbB)).toEqual([{ unp: 'UNP-XW', source: 'eop' }]); // OCDS twin converged away
+ expect(rollup(dbB)).toEqual({ annex_count: 1, current_value: 1500 });
+ expect(sqlite(dbB, 'PRAGMA foreign_key_check;').trim()).toBe('');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
it('does not insert an OCDS duplicate after an existing EOP contract', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-'));
const dbPath = resolve(dir, 'test.sqlite');
@@ -658,6 +863,10 @@ describe('refresh-slice EOP base derivation', () => {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, workStagingSchemaPath);
seedEopOnlySharedNumber(dbPath);
readScript(dbPath, refreshSlicePath);
@@ -709,6 +918,10 @@ describe('refresh-slice EOP base derivation', () => {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, workStagingSchemaPath);
sqlite(
dbPath,
@@ -760,6 +973,10 @@ describe('refresh-slice EOP base derivation', () => {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, workStagingSchemaPath);
sqlite(
dbPath,
@@ -897,6 +1114,10 @@ describe('refresh-slice EOP base derivation', () => {
readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
readScript(dbPath, workStagingSchemaPath);
sqlite(
dbPath,
diff --git a/packages/db/src/related-persons-sql.test.ts b/packages/db/src/related-persons-sql.test.ts
index af338041..ddcf4287 100644
--- a/packages/db/src/related-persons-sql.test.ts
+++ b/packages/db/src/related-persons-sql.test.ts
@@ -1,6 +1,6 @@
///
import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync } from 'node:fs';
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -10,8 +10,11 @@ import {
LEADERBOARD_SQL,
LINK_CONTRACTS_LIMIT,
LINK_CONTRACTS_SQL,
+ LINK_SELECT,
OFFICIAL_SQL,
+ SURFACED_OWNERSHIP,
} from './queries/related-persons';
+import { SEARCH_HITS_SQL } from './queries/search';
// Integration test for the свързани-лица SQL. The query layer's unit tests (queries/related-persons.test)
// use a fake D1 and never run the aggregation; this runs the EXACT exported SQL against a real SQLite
@@ -22,6 +25,8 @@ import {
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration2 = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// …and 0006: SURFACED_OWNERSHIP now requires a Trade Register evidence seal (#279, ADR-0033).
+const migration9 = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
function sqlite(dbPath: string, sql: string): string {
return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' }).trim();
@@ -122,6 +127,13 @@ INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, co
-- Rollup row for the awarding body — the per-authority capture-share denominator the read query LEFT JOINs.
INSERT INTO authority_totals (authority_id, name, spent_eur, contracts, suppliers, avg_eur) VALUES
('a:1','ОБЩИНА ТЕСТ',50000000,10,4,5000000);
+
+-- Every link carries a Trade Register evidence seal (#279, ADR-0033). SURFACED_OWNERSHIP now requires
+-- one, so a fixture without seals renders an EMPTY surface and every assertion below passes vacuously.
+-- Derived from interest_links itself, so a row added to the fixture later is sealed automatically and
+-- cannot silently drop off the surface.
+INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links;
`;
describe('свързани-лица SQL (real SQLite)', () => {
@@ -131,6 +143,7 @@ describe('свързани-лица SQL (real SQLite)', () => {
try {
readScript(dbPath, migration0);
readScript(dbPath, migration2);
+ readScript(dbPath, migration9);
sqlite(dbPath, FIXTURE);
return fn(dbPath);
} finally {
@@ -193,7 +206,9 @@ describe('свързани-лица SQL (real SQLite)', () => {
INSERT INTO persons (id, name) VALUES ('person:zero','Нула Тестов');
INSERT INTO interest_links
(id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier, relation, interest_class, contemporaneous, own_institution, evidence_count, first_declared_year, last_declared_year, contract_count, contract_value_eur, first_contract_year, last_contract_year, status) VALUES
- ('il:zerofam','person:zero|666|family','person:zero','eik:666','666','НУЛА ООД','exact_name_key','v1','B_distinctive','related','family_ownership',0,'none',1,'2020','2021',0,0,NULL,NULL,'published');`,
+ ('il:zerofam','person:zero|666|family','person:zero','eik:666','666','НУЛА ООД','exact_name_key','v1','B_distinctive','related','family_ownership',0,'none',1,'2020','2021',0,0,NULL,NULL,'published');
+ INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links WHERE link_key NOT IN (SELECT link_key FROM interest_link_evidence);`,
);
const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
expect(board.some((r) => r.official === 'Нула Тестов')).toBe(false); // no live contracts → gated out
@@ -217,6 +232,8 @@ describe('свързани-лица SQL (real SQLite)', () => {
(id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier, relation, interest_class, contemporaneous, own_institution, evidence_count, first_declared_year, last_declared_year, contract_count, contract_value_eur, first_contract_year, last_contract_year, status) VALUES
('il:alen','person:alen|701','person:alen','eik:701','701','АЛЕН КО ООД','exact_name_key','v1','B_distinctive','owns','private_ownership',1,'none',1,'2020','2021',1,1000000,'2021','2021','published'),
('il:boyan','person:boyan|702','person:boyan','eik:702','702','БОЯН КО ООД','exact_name_key','v1','B_distinctive','owns','private_ownership',1,'none',1,'2020','2021',1,10000000,'2021','2021','published');
+ INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links WHERE link_key NOT IN (SELECT link_key FROM interest_link_evidence);
INSERT INTO tenders (id, source_id, title, authority_id, procedure_type) VALUES
('t:71','unp71','Обект А','a:1','открита процедура'),('t:72','unp72','Обект Б','a:1','открита процедура');
INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, contract_number, amount_eur) VALUES
@@ -322,6 +339,8 @@ describe('свързани-лица SQL (real SQLite)', () => {
(id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier, relation, interest_class, contemporaneous, own_institution, evidence_count, first_declared_year, last_declared_year, contract_count, contract_value_eur, first_contract_year, last_contract_year, status) VALUES
('il:dubl-self','person:dubl|800','person:dubl','eik:800','800','ДУБЪЛ ЕООД','exact_name_key','v1','B_distinctive','owns','private_ownership',0,'none',1,'2020','2022',2,60000,'2021','2022','published'),
('il:dubl-fam','person:dubl|800|family','person:dubl','eik:800b','800','ДУБЪЛ ЕООД','exact_name_key','v1','B_distinctive','related','family_ownership',0,'none',1,'2020','2022',2,60000,'2021','2022','published');
+ INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links WHERE link_key NOT IN (SELECT link_key FROM interest_link_evidence);
INSERT INTO tenders (id, source_id, title, authority_id, procedure_type) VALUES ('t:80','unp80','Обект Дубъл','a:1','открита процедура');
INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, contract_number, amount_eur) VALUES ('c:80','t:80','eik:800',60000,'EUR','2021-05-01','Д-80',60000);`,
);
@@ -350,6 +369,8 @@ describe('свързани-лица SQL (real SQLite)', () => {
(id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier, relation, interest_class, contemporaneous, own_institution, evidence_count, first_declared_year, last_declared_year, contract_count, contract_value_eur, first_contract_year, last_contract_year, status) VALUES
('il:asim-self-held','person:asim|850','person:asim','eik:850','850','АСИМ ЕООД','exact_name_key','v1','C_hold','owns','private_ownership',0,'none',1,'2020','2022',2,40000,'2021','2022','held'),
('il:asim-fam','person:asim|850|family','person:asim','eik:850','850','АСИМ ЕООД','exact_name_key','v1','B_distinctive','related','family_ownership',0,'none',1,'2020','2022',2,40000,'2021','2022','published');
+ INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links WHERE link_key NOT IN (SELECT link_key FROM interest_link_evidence);
INSERT INTO tenders (id, source_id, title, authority_id, procedure_type) VALUES ('t:85','unp85','Обект Асим','a:1','открита процедура');
INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, contract_number, amount_eur) VALUES ('c:85','t:85','eik:850',40000,'EUR','2021-05-01','Д-85',40000);`,
);
@@ -475,7 +496,9 @@ describe('свързани-лица SQL (real SQLite)', () => {
INSERT INTO persons (id, name) VALUES ('person:praz','Празен Тестов');
INSERT INTO interest_links
(id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier, relation, interest_class, contemporaneous, own_institution, evidence_count, first_declared_year, last_declared_year, contract_count, contract_value_eur, first_contract_year, last_contract_year, status) VALUES
- ('il:praz','person:praz|900','person:praz','eik:900','900','ПРАЗЕН ООД','exact_name_key','v1','B_distinctive','owns','private_ownership',1,'exact',1,'2020','2022',7,7000000,'2021','2022','published');`,
+ ('il:praz','person:praz|900','person:praz','eik:900','900','ПРАЗЕН ООД','exact_name_key','v1','B_distinctive','owns','private_ownership',1,'exact',1,'2020','2022',7,7000000,'2021','2022','published');
+ INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links WHERE link_key NOT IN (SELECT link_key FROM interest_link_evidence);`,
);
const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
expect(board.some((r) => r.official === 'Празен Тестов')).toBe(false); // gated out — no live contracts
@@ -504,4 +527,173 @@ describe('свързани-лица SQL (real SQLite)', () => {
);
});
});
+
+ // ── the evidence seal gate (#279, ADR-0033 decision 1) ──────────────────────
+ //
+ // This block exists because the gate had NO test pressure at all: deleting the entire
+ // `EXISTS (… interest_link_evidence …)` clause out of SURFACED_OWNERSHIP left the whole db suite
+ // green (361/361), because every fixture above seals every link 'document'. That clause is the only
+ // SQL between a WITHHELD link and a named public claim that a specific official owns a specific
+ // company, so an untested one is the most expensive kind of dead rail.
+ //
+ // Each rung below is a REAL withholding outcome of evidence.mjs, not an invented value:
+ // refuted — the register contradicts the declared stake
+ // bar_joint_stock — an АД/ЕАД, where a declared parcel of shares is not a material conflict
+ // unknown — no rung reached; the deed proves nothing either way
+ // outside_tr — ДЗЗД/BULSTAT, not in the Trade Register at all
+ // …plus a link with NO seal row whatsoever, which is what a half-loaded run produces.
+ describe('a link withheld by its evidence seal never reaches any public query', () => {
+ // All six share the SAME winner (eik 111, which has live contracts) and the same shape, so the
+ // ONLY thing that differs is the seal. Without that, an absent row could be absent for an unrelated
+ // reason and every assertion here would pass vacuously — which is exactly the bug being fixed.
+ const WITHHELD = [
+ ['refuted', 'person:ref', 'Оборен Тестов'],
+ ['bar_joint_stock', 'person:bar', 'Акционер Тестов'],
+ ['unknown', 'person:unk', 'Неясен Тестов'],
+ ['outside_tr', 'person:out', 'Извън Тестов'],
+ ] as const;
+
+ function seedRungs(dbPath: string): void {
+ const people: [string, string][] = [
+ ...WITHHELD.map(([, id, name]) => [id, name] as [string, string]),
+ ['person:none', 'Безпечатен Тестов'],
+ ['person:ok', 'Потвърден Тестов'],
+ ];
+ const links: string[] = [...WITHHELD.map(([, id]) => id), 'person:none', 'person:ok'];
+ sqlite(
+ dbPath,
+ `INSERT INTO persons (id, name) VALUES ${people.map(([id, n]) => `('${id}','${n}')`).join(',')};
+ INSERT INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier, relation, interest_class, contemporaneous, own_institution, evidence_count, first_declared_year, last_declared_year, contract_count, contract_value_eur, first_contract_year, last_contract_year, status) VALUES
+ ${links
+ .map(
+ (p) =>
+ `('il:${p.split(':')[1]}','${p}|111','${p}','eik:111','111','ТРЕЙС ГРУП ХОЛД АД','exact_name_key','v1','B_distinctive','owns','private_ownership',1,'none',1,'2019','2023',3,1000,'2020','2021','published')`,
+ )
+ .join(',')};
+ INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status) VALUES
+ ${WITHHELD.map(([kind, id]) => `('${id}|111','${kind}',NULL,NULL,'2026-08-05','tr-rules-1','live')`).join(',')},
+ ('person:ok|111','confirmed',NULL,'seat:СОФИЯ','2026-08-05','tr-rules-1','live');`,
+ );
+ // person:none deliberately gets NO evidence row at all.
+ }
+
+ it('the POSITIVE CONTROL surfaces — so every absence below is caused by the seal, not the fixture', () => {
+ withDb((dbPath) => {
+ seedRungs(dbPath);
+ const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
+ expect(board.some((r) => r.official === 'Потвърден Тестов')).toBe(true);
+ expect(rows(dbPath, lit(OFFICIAL_SQL, 'person:ok'))).toHaveLength(1);
+ });
+ });
+
+ for (const [kind, personId, name] of WITHHELD) {
+ it(`'${kind}' is withheld from the leaderboard, the official page, the company page and the drill-down`, () => {
+ withDb((dbPath) => {
+ seedRungs(dbPath);
+ const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
+ expect(board.some((r) => r.official === name)).toBe(false);
+ expect(board.some((r) => r.link_key === `${personId}|111`)).toBe(false);
+ // The official's own page must 404 rather than render a withheld claim under their name…
+ expect(rows(dbPath, lit(OFFICIAL_SQL, personId))).toHaveLength(0);
+ // …the company page must not list them among that winner's office-holders…
+ expect(rows(dbPath, lit(COMPANY_SQL, '111')).some((r) => r.official === name)).toBe(
+ false,
+ );
+ // …and the drill-down must not enumerate the contracts of a link it may not name.
+ expect(rows(dbPath, lit(LINK_CONTRACTS_SQL, `${personId}|111`))).toHaveLength(0);
+ });
+ });
+ }
+
+ it('a link with NO seal row at all is withheld too — a half-loaded run must not publish', () => {
+ // The LEFT JOIN in LINK_SELECT would happily return this row with NULL evidence columns; only the
+ // EXISTS gate keeps it off the surface. This is the case a partial load actually produces.
+ withDb((dbPath) => {
+ seedRungs(dbPath);
+ const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
+ expect(board.some((r) => r.official === 'Безпечатен Тестов')).toBe(false);
+ expect(rows(dbPath, lit(OFFICIAL_SQL, 'person:none'))).toHaveLength(0);
+ expect(
+ rows(dbPath, lit(COMPANY_SQL, '111')).some((r) => r.official === 'Безпечатен Тестов'),
+ ).toBe(false);
+ expect(rows(dbPath, lit(LINK_CONTRACTS_SQL, 'person:none|111'))).toHaveLength(0);
+ });
+ });
+
+ it('the company page shows ONLY the sealed office-holders of that winner', () => {
+ // Four withheld links and one confirmed link all point at eik 111, alongside the base fixture's
+ // Иван. A gate that let any withheld rung through would show up here as an extra name.
+ withDb((dbPath) => {
+ seedRungs(dbPath);
+ const names = rows(dbPath, lit(COMPANY_SQL, '111')).map((r) => r.official);
+ expect(names.sort()).toEqual(['Иван Минев', 'Потвърден Тестов']);
+ });
+ });
+ });
+});
+
+// The read-time contract join and the WRITER that fills contract_count/contract_value_eur must use the
+// same join shape. On the read side `tenders`/`authorities` are never projected, so both joins read as
+// dead and a reviewer will eventually propose deleting them (one did — cefothe on #309). Deleting them
+// there alone widens the read past the stored aggregate: contemporaneous counts could exceed
+// contract_count, and the EXISTS gate would surface links the zero-contract gate excluded. Nothing but a
+// test can hold a TypeScript template string and a .mjs prepared statement to the same shape.
+describe('the read-time contract join matches the writer that stored the aggregate', () => {
+ const writer = readFileSync(resolve(root, 'scripts/cacbg/load.mjs'), 'utf8');
+ // The writer's per-winner contract query, located by its projection rather than by line number.
+ const writerJoin = /FROM contracts c JOIN tenders t ON[^"]*?WHERE b\.eik_normalized/.exec(
+ writer,
+ )?.[0];
+
+ it('the writer still joins contracts→tenders→authorities→bidders', () => {
+ expect(writerJoin, 'load.mjs per-winner contract query not found').toBeTruthy();
+ for (const rel of ['tenders', 'authorities', 'bidders']) expect(writerJoin).toContain(rel);
+ });
+
+ it('CONTRACT_JOIN joins the same four relations, in the same direction', () => {
+ // Compared by RELATION and join condition, not by text: the two are written in different languages
+ // and alias differently (cc/tt/aa/bb vs c/t/a/b), so only the shape is comparable.
+ const flat = LINK_SELECT.replace(/\s+/g, ' ');
+ expect(flat).toContain('FROM contracts cc');
+ expect(flat).toContain('JOIN tenders tt ON tt.id = cc.tender_id');
+ expect(flat).toContain('JOIN authorities aa ON aa.id = tt.authority_id');
+ expect(flat).toContain('JOIN bidders bb ON bb.id = cc.bidder_id');
+ // …and the writer's equivalents, so a change to either side fails here.
+ expect(writerJoin).toContain('JOIN tenders t ON t.id=c.tender_id');
+ expect(writerJoin).toContain('JOIN authorities a ON a.id=t.authority_id');
+ expect(writerJoin).toContain('JOIN bidders b ON b.id=c.bidder_id');
+ });
+});
+
+// The evidence-seal gate has FOUR copies, and they must say the same thing. Reviewers found the fourth
+// (the company-search badge) checking `status='published'` alone, so search advertised a свързани-лица
+// claim about a named official that the detail page correctly withheld. `refresh-slice.sql` matters for a
+// different reason: it runs on the 6-hourly cron, so drift there silently keeps officials in the search
+// index whose links no longer surface. Nothing but a test binds SQL in three languages and two file types.
+describe('the evidence-seal gate is identical in all four places it is written', () => {
+ const sources: [string, string][] = [
+ ['related-persons.ts (SURFACED_OWNERSHIP)', SURFACED_OWNERSHIP],
+ ['search.ts (company badge)', SEARCH_HITS_SQL],
+ ['precompute.sql', readFileSync(resolve(root, 'scripts/precompute.sql'), 'utf8')],
+ ['refresh-slice.sql', readFileSync(resolve(root, 'scripts/refresh-slice.sql'), 'utf8')],
+ ];
+
+ it.each(sources)('%s requires a publishing evidence seal', (_label, sql) => {
+ // Whitespace-insensitive: the four are formatted for their own file, and a line break is not drift.
+ const flat = sql.replace(/\s+/g, ' ');
+ expect(flat).toMatch(
+ /EXISTS \( ?SELECT 1 FROM interest_link_evidence e WHERE e\.link_key = il\.link_key AND e\.evidence_kind IN \('document','confirmed'\) ?\)/,
+ );
+ });
+
+ it.each(sources)('%s admits exactly the two publishing rungs', (_label, sql) => {
+ // The rung list is the gate. A future rung added to one copy and not the others either leaks an
+ // unproven claim or silently drops a proven one, depending on which copy gained it.
+ const kinds = [...sql.matchAll(/evidence_kind IN \(([^)]*)\)/g)].map((m) =>
+ m[1]!.replace(/\s|'/g, ''),
+ );
+ expect(kinds.length).toBeGreaterThan(0);
+ for (const k of kinds) expect(k).toBe('document,confirmed');
+ });
});
diff --git a/packages/db/src/search-sql.test.ts b/packages/db/src/search-sql.test.ts
index 58c25205..59f9a95c 100644
--- a/packages/db/src/search-sql.test.ts
+++ b/packages/db/src/search-sql.test.ts
@@ -16,6 +16,7 @@ import { SEARCH_HITS_SQL, SEARCH_HITS_SQL_NO_CONFLICT } from './queries/search';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration2 = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+const migration9 = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
function readScript(dbPath: string, path: string): void {
execFileSync('sqlite3', ['-bail', dbPath], { input: `.read ${path}\n`, stdio: 'pipe' });
@@ -44,17 +45,26 @@ INSERT INTO bidders (id, name, eik_normalized, eik_valid, kind) VALUES
('eik:333','ГАМА ООД','333333333',1,'company'),
('eik:444','ДЕЛТА ООД','444444444',1,'company'),
('eik:555','ЕПСИЛОН ООД','555555555',1,'company'),
- ('eik:666','ЗЕТА ООД','666666666',1,'company');
+ ('eik:666','ЗЕТА ООД','666666666',1,'company'),
+ -- #279: two winners with a PUBLISHED link that must not badge. ЙОТА's link carries NO evidence seal at
+ -- all (a legacy row, a partial run, or a loader bug); КАПА's seal is a WITHHOLDING rung. The detail page
+ -- refuses both via SURFACED_OWNERSHIP; the badge has to agree, or search advertises an unproven claim
+ -- about a named official on a page that then shows nothing.
+ ('eik:777','ЙОТА ООД','777777777',1,'company'),
+ ('eik:888','КАПА ООД','888888888',1,'company');
INSERT INTO company_totals (bidder_id, name, kind, eik, eik_valid, won_eur, contracts, authorities) VALUES
('eik:111','АЛФА ООД','company','111111111',1,1000000,1,1),
('eik:222','БЕТА ООД','company','222222222',1,500000,1,1),
('eik:333','ГАМА ООД','company','333333333',1,200000,1,1),
('eik:444','ДЕЛТА ООД','company','444444444',1,300000,1,1),
('eik:555','ЕПСИЛОН ООД','company','555555555',1,700000,1,1),
- ('eik:666','ЗЕТА ООД','company','666666666',1,900000,1,1);
+ ('eik:666','ЗЕТА ООД','company','666666666',1,900000,1,1),
+ ('eik:777','ЙОТА ООД','company','777777777',1,400000,1,1),
+ ('eik:888','КАПА ООД','company','888888888',1,600000,1,1);
INSERT INTO persons (id, name) VALUES
('person:ИВАН МИНЕВ','Иван Минев'),('person:ГЕОРГИ ПЕТРОВ','Георги Петров'),
- ('person:ДАНА ФАМ','Дана Фам'),('person:БОРИС БОРД','Борис Борд'),('person:ДВОЕН ТЕСТ','Двоен Тест');
+ ('person:ДАНА ФАМ','Дана Фам'),('person:БОРИС БОРД','Борис Борд'),('person:ДВОЕН ТЕСТ','Двоен Тест'),
+ ('person:БЕЗ ПЕЧАТ','Без Печат'),('person:ЗАДЪРЖАН ПЕЧАТ','Задържан Печат');
INSERT INTO declarations (id, person_id, xml_file, control_hash, folder_year, declared_year, template, category, institution, position, source_url) VALUES
('decl:i','person:ИВАН МИНЕВ','i.xml','H1','2024','2023','assets','','ОБЩИНА РУСЕ','', 'https://register.cacbg.bg/2024/i.xml'),
('decl:g','person:ГЕОРГИ ПЕТРОВ','g.xml','H2','2024','2023','assets','','МИНИСТЕРСТВО Х','', 'https://register.cacbg.bg/2024/g.xml'),
@@ -75,15 +85,30 @@ INSERT INTO interest_links
-- so the redundant-family collapse (ADR-0032) DROPS the family link from the index; the winner's €50k counts
-- ONCE, not €100k — no de-anonymization vector, no double-count.
('il:ds','person:ДВОЕН ТЕСТ|666','person:ДВОЕН ТЕСТ','eik:666','666666666','ЗЕТА ООД','exact_name_key','v1','B_distinctive','owns','private_ownership',0,'none',1,'2020','2023',1,50000,'2021','2021','published'),
- ('il:dfam','person:ДВОЕН ТЕСТ|666|family','person:ДВОЕН ТЕСТ','eik:666','666666666','ЗЕТА ООД','exact_name_key','v1','B_distinctive','related','family_ownership',0,'none',1,'2020','2023',1,50000,'2021','2021','published');
+ ('il:dfam','person:ДВОЕН ТЕСТ|666|family','person:ДВОЕН ТЕСТ','eik:666','666666666','ЗЕТА ООД','exact_name_key','v1','B_distinctive','related','family_ownership',0,'none',1,'2020','2023',1,50000,'2021','2021','published'),
+ -- published, ownership class, live contracts — and NO seal. Everything the badge used to check, passed.
+ ('il:no','person:БЕЗ ПЕЧАТ|777','person:БЕЗ ПЕЧАТ','eik:777','777777777','ЙОТА ООД','exact_name_key','v1','document','owns','private_ownership',0,'none',1,'2020','2023',1,400000,'2021','2021','published'),
+ -- published, and sealed with a rung that WITHHOLDS (ADR-0035). A seal existing is not a seal permitting.
+ ('il:un','person:ЗАДЪРЖАН ПЕЧАТ|888','person:ЗАДЪРЖАН ПЕЧАТ','eik:888','888888888','КАПА ООД','exact_name_key','v1','document_uncorroborated','owns','private_ownership',0,'none',1,'2020','2023',1,600000,'2021','2021','published');
+INSERT INTO interest_link_evidence (link_key, evidence_kind, lookup_date, rules_version, live_status) VALUES
+ ('person:ИВАН МИНЕВ|111','document','2026-08-12','tr-rules-1','live'),
+ ('person:ИВАН МИНЕВ|333','confirmed','2026-08-12','tr-rules-1','live'),
+ ('person:ДАНА ФАМ|444','confirmed','2026-08-12','tr-rules-1','live'),
+ ('person:БОРИС БОРД|555','document','2026-08-12','tr-rules-1','live'),
+ ('person:ДВОЕН ТЕСТ|666','document','2026-08-12','tr-rules-1','live'),
+ ('person:ДВОЕН ТЕСТ|666|family','document','2026-08-12','tr-rules-1','live'),
+ ('person:ЗАДЪРЖАН ПЕЧАТ|888','document_uncorroborated','2026-08-12','tr-rules-1','live');
INSERT INTO authorities (id, name) VALUES ('a:1','ВЕДОМСТВО ТЕСТ');
INSERT INTO tenders (id, source_id, title, authority_id, procedure_type) VALUES
- ('t:1','s1','Т1','a:1','open'),('t:3','s3','Т3','a:1','open'),('t:4','s4','Т4','a:1','open'),('t:6','s6','Т6','a:1','open');
+ ('t:1','s1','Т1','a:1','open'),('t:3','s3','Т3','a:1','open'),('t:4','s4','Т4','a:1','open'),
+ ('t:6','s6','Т6','a:1','open'),('t:7','s7','Т7','a:1','open'),('t:8','s8','Т8','a:1','open');
INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, contract_number, amount_eur) VALUES
('c:1','t:1','eik:111',1000000,'EUR','2021-05-01','N1',1000000),
('c:3','t:3','eik:333',200000,'EUR','2021-05-01','N3',200000),
('c:4','t:4','eik:444',300000,'EUR','2021-05-01','N4',300000),
- ('c:6','t:6','eik:666',50000,'EUR','2021-05-01','N6',50000);
+ ('c:6','t:6','eik:666',50000,'EUR','2021-05-01','N6',50000),
+ ('c:7','t:7','eik:777',400000,'EUR','2021-05-01','N7',400000),
+ ('c:8','t:8','eik:888',600000,'EUR','2021-05-01','N8',600000);
`;
// Search-index population — a STRUCTURAL proxy for scripts/precompute.sql's officials block: it exercises the
@@ -116,6 +141,7 @@ function withDb(fn: (dbPath: string) => void): void {
try {
readScript(dbPath, migration0);
readScript(dbPath, migration2);
+ readScript(dbPath, migration9);
exec(dbPath, FIXTURE);
exec(dbPath, POPULATE_INDEX);
fn(dbPath);
@@ -151,6 +177,27 @@ describe('search свързани-лица SQL', () => {
});
});
+ it('the badge requires an evidence SEAL, not merely status=published (#279)', () => {
+ // The detail page gate is belt-and-braces — status AND a publishing seal (SURFACED_OWNERSHIP). The
+ // badge checked status alone, so an evidence-less published row advertised a свързани-лица claim on a
+ // named official in search while the page it links to correctly withheld it. Search is the wider
+ // surface of the two: it is what a reader sees before deciding to look.
+ withDb((dbPath) => {
+ // NO seal at all — the legacy/partial-run/loader-bug shape.
+ const iota = rows(dbPath, lit(SEARCH_HITS_SQL, 'company', 'йота*', 10));
+ expect(iota).toHaveLength(1);
+ expect(iota[0]!.has_conflict).toBe(0);
+ // Sealed, but with a rung that WITHHOLDS. A seal existing is not a seal permitting.
+ const kapa = rows(dbPath, lit(SEARCH_HITS_SQL, 'company', 'капа*', 10));
+ expect(kapa).toHaveLength(1);
+ expect(kapa[0]!.has_conflict).toBe(0);
+ // POSITIVE CONTROL — a properly sealed link still badges, on both publishing rungs. Without this a
+ // gate that rejected everything would satisfy the two assertions above.
+ expect(rows(dbPath, lit(SEARCH_HITS_SQL, 'company', 'алфа*', 10))[0]!.has_conflict).toBe(1);
+ expect(rows(dbPath, lit(SEARCH_HITS_SQL, 'company', 'гама*', 10))[0]!.has_conflict).toBe(1);
+ });
+ });
+
it('indexes family_ownership (ADR-0032) but NEVER management_role/ex-officio (interest_class gate)', () => {
withDb((dbPath) => {
// family_ownership now reaches the index identically to self (ADR-0032): Дана (a relative's stake) IS
diff --git a/packages/db/src/seed-surface.test.ts b/packages/db/src/seed-surface.test.ts
new file mode 100644
index 00000000..78a70aaa
--- /dev/null
+++ b/packages/db/src/seed-surface.test.ts
@@ -0,0 +1,95 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { COMPANY_SQL, LEADERBOARD_SQL, OFFICIAL_SQL } from './queries/related-persons';
+
+// The dev seed exists for ONE reason: `/conflicts` renders an empty surface without it, so the whole
+// feature is unverifiable in a browser. That makes the seed a promise about the read gate, and a promise
+// nothing checked — it was written against SURFACED_OWNERSHIP as it stood, then #279 added the evidence
+// seal requirement to that same predicate and the fixture silently stopped surfacing. A fresh dev DB
+// rendered empty, which is exactly the state the seed was created to prevent.
+//
+// So: run the REAL migrations, the REAL seed.sql, and the REAL exported queries. Any future change to
+// the publishing gate that the seed does not keep up with now fails here rather than in someone's
+// browser, days later, as „the local app looks broken".
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const migrations = [
+ 'packages/db/migrations/0000_init.sql',
+ 'packages/db/migrations/0003_related_persons_foundation.sql',
+ 'packages/db/migrations/0009_interest_link_evidence.sql',
+].map((m) => resolve(root, m));
+const seed = resolve(root, 'scripts/seed.sql');
+
+function seededDb(fn: (dbPath: string) => T): T {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-seed-'));
+ const dbPath = resolve(dir, 'seed.sqlite');
+ try {
+ for (const file of [...migrations, seed])
+ execFileSync('sqlite3', ['-bail', dbPath], { input: `.read ${file}\n`, stdio: 'pipe' });
+ return fn(dbPath);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+function rows(dbPath: string, sql: string): Record[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath], { input: sql, encoding: 'utf8' }).trim();
+ return out ? JSON.parse(out) : [];
+}
+function lit(sql: string, ...vals: (string | number)[]): string {
+ let i = 0;
+ return sql.replace(/\?/g, () => {
+ const v = vals[i++];
+ return typeof v === 'number' ? String(v) : `'${String(v).replace(/'/g, "''")}'`;
+ });
+}
+
+describe('scripts/seed.sql produces a surface a developer can actually see', () => {
+ it('the leaderboard is NOT empty — the seed survives every gate in SURFACED_OWNERSHIP', () => {
+ seededDb((dbPath) => {
+ const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
+ expect(board.length).toBeGreaterThan(0);
+ // Both published outcomes the seed is built around: the official's own stake and a relative's.
+ expect(board.map((r) => r.relation).sort()).toEqual(['owns', 'related']);
+ });
+ });
+
+ it('every surfaced seed link carries a real publishing seal, not a coincidence', () => {
+ seededDb((dbPath) => {
+ for (const r of rows(dbPath, lit(LEADERBOARD_SQL, 100)))
+ expect(['document', 'confirmed']).toContain(r.evidence_kind);
+ });
+ });
+
+ it('the held joint-stock link stays off the surface — the seed keeps its negative case', () => {
+ // ГАМА ИНВЕСТ АД is seeded as a withheld link precisely so a change to the publishing rule shows up
+ // as a before/after rather than as an empty page either way. If it ever surfaces, the materiality
+ // bar (ADR-0022) or the seal gate has quietly stopped working.
+ seededDb((dbPath) => {
+ const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
+ expect(board.some((r) => String(r.company).includes('ГАМА ИНВЕСТ'))).toBe(false);
+ expect(rows(dbPath, lit(COMPANY_SQL, '204556676'))).toHaveLength(0);
+ });
+ });
+
+ it('the official and company pages render too — not just the leaderboard', () => {
+ seededDb((dbPath) => {
+ const board = rows(dbPath, lit(LEADERBOARD_SQL, 100));
+ const own = board.find((r) => r.relation === 'owns')!;
+ expect(rows(dbPath, lit(OFFICIAL_SQL, String(own.person_id))).length).toBeGreaterThan(0);
+ expect(rows(dbPath, lit(COMPANY_SQL, String(own.eik))).length).toBeGreaterThan(0);
+ });
+ });
+
+ it('re-running the seed is idempotent — INSERT OR IGNORE, no duplicated links', () => {
+ seededDb((dbPath) => {
+ const before = rows(dbPath, lit(LEADERBOARD_SQL, 100)).length;
+ execFileSync('sqlite3', ['-bail', dbPath], { input: `.read ${seed}\n`, stdio: 'pipe' });
+ expect(rows(dbPath, lit(LEADERBOARD_SQL, 100))).toHaveLength(before);
+ });
+ });
+});
diff --git a/packages/db/src/value-flag-annex-step-sql.test.ts b/packages/db/src/value-flag-annex-step-sql.test.ts
new file mode 100644
index 00000000..8c52bd89
--- /dev/null
+++ b/packages/db/src/value-flag-annex-step-sql.test.ts
@@ -0,0 +1,338 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// The mis-keyed annex rule (#248): annex_suspect used to need the aggregate current/signing ratio to
+// reach 100x, so a single annex with a mis-typed value that pushed a contract to, say, 30x its signed
+// value was served at face value. The new rule is a CONJUNCTION: one annex step jumped >=10x AND the
+// aggregate ended >=5x over signing. Both halves are load-bearing - the corpus has chains with a
+// 36,058x single step whose aggregate ends BELOW signing (a later annex corrects the typo), where
+// flagging would RAISE the shown value, and slow legitimate chains that double a few times without any
+// single suspicious step.
+//
+// The flag CASE lives in five copies across the two derive paths, plus a re-flag guard in the
+// refresh-slice reconciliation pass, so the rule is exercised through the REAL scripts (in their real
+// pipeline order) on a real SQLite database. A copy left behind in normalize-raw, or in the
+// reconciliation pass, fails here rather than in production. The two refresh-slice INSERT-time copies
+// are the one thing these tests CANNOT pin in isolation: refresh-slice promotes the window's
+// amendments itself and its reconciliation pass then re-flags every amended contract, so a stale
+// INSERT copy is always superseded in-script and never observable in the end state.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
+const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// refresh-slice.sql's свързани-лица block reads interest_link_evidence (#279, ADR-0033), so the script
+// only parses against a DB that also has 0006 — without it sqlite3 aborts on „no such table" before it
+// ever reaches the value_flag CASE these tests are about.
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+// #305 Tier-2: served amendments gained value_restated/value_treatment (promote + refresh-slice write them).
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
+const derivePath = resolve(root, 'scripts/derive-amendments.sql');
+const promotePath = resolve(root, 'scripts/promote-amendments.sql');
+// Real pipeline order (scripts/import.mjs): the full derive runs derive-amendments before
+// normalize-raw; the slice derive runs derive-amendments, and promote-amendments has populated the
+// served amendments table that refresh-slice's reconciliation pass re-rolls current_value from.
+const etlRuns = [
+ ['normalize-raw', [derivePath, resolve(root, 'scripts/normalize-raw.sql')]],
+ ['refresh-slice', [derivePath, promotePath, resolve(root, 'scripts/refresh-slice.sql')]],
+] as const;
+
+function sqlite(dbPath: string, sql: string): string {
+ return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', ['-bail', dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function withEtlDb(label: string, run: (dbPath: string) => void): void {
+ const dir = mkdtempSync(resolve(tmpdir(), `sigma-annexstep-${label}-`));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ readScript(dbPath, schemaPath);
+ readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
+ readScript(dbPath, stagingPath);
+ run(dbPath);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+/** Signing value in BGN; exactly 100_000 EUR so the multiples below read directly. The procedure
+ * estimate is the same, which keeps every case away from the estimate-driven flags (the стотинки
+ * band sits at 95x-105x the estimate; the largest aggregate used here is 30x). */
+const SIGNING_BGN = 195_583;
+const AUTH_EIK = '000695114';
+const BIDDER_EIK = '831646048';
+
+interface AmendmentStep {
+ before: number | null;
+ after: number;
+ publishedAt: string;
+}
+
+interface Case {
+ unp: string;
+ steps: AmendmentStep[];
+}
+
+function seedContracts(dbPath: string, cases: { unp: string }[]): void {
+ const tenders = cases
+ .map(
+ (c) =>
+ `('eop:tenders:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'public', ${SIGNING_BGN}, 'BGN')`,
+ )
+ .join(',\n');
+ const contracts = cases
+ .map(
+ (c) =>
+ `('eop:contracts:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'C-${c.unp}', '2026-06-01', ${SIGNING_BGN}, 'BGN', '${BIDDER_EIK}', 'Тестов изпълнител')`,
+ )
+ .join(',\n');
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, fetched_at, unp, authority_eik, authority_name, authority_type, estimated_value, currency)
+ VALUES ${tenders};
+
+ INSERT INTO raw_contracts
+ (source, fetched_at, unp, authority_eik, authority_name, contract_number,
+ contract_date, signing_value, currency, contractor_eik, contractor_name)
+ VALUES ${contracts};`,
+ );
+}
+
+function seedAmendments(dbPath: string, cases: Case[]): void {
+ const rows = cases
+ .flatMap((c) =>
+ c.steps.map(
+ (s, i) =>
+ `('eop:annexes:${c.unp}', '2026-06-01T00:00:00Z', 'A-${c.unp}-${i + 1}', '${c.unp}', 'C-${c.unp}', '${s.publishedAt}', ${s.before ?? 'NULL'}, ${s.after}, 'BGN')`,
+ ),
+ )
+ .join(',\n');
+ if (!rows) return;
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_amendments
+ (source, fetched_at, document_number, unp, contract_number, published_at,
+ value_before, value_after, currency)
+ VALUES ${rows};`,
+ );
+}
+
+interface Row {
+ id: string;
+ value_flag: string;
+ amount_eur: number | null;
+}
+
+const flagsByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT id, value_flag, ROUND(amount_eur) AS amount_eur FROM contracts`,
+ );
+ const out = new Map();
+ for (const r of rows) {
+ const unp = r.id.split(':')[2]!;
+ out.set(unp, r);
+ }
+ return out;
+};
+
+describe('mis-keyed annex step in the value_flag CASE', () => {
+ for (const [label, scriptPaths] of etlRuns) {
+ it(`${label}: flags one big annex step whose aggregate stays inflated, and repairs to signing`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // A single mis-typed annex: 30x in one step, nothing corrects it. Far below the 100x the
+ // old rule needed.
+ {
+ unp: 'UNP-STEP30',
+ steps: [{ before: SIGNING_BGN, after: SIGNING_BGN * 30, publishedAt: '2026-06-10' }],
+ },
+ // Both thresholds exactly at the boundary: step exactly 10x, aggregate exactly 5x.
+ {
+ unp: 'UNP-EDGE',
+ steps: [{ before: SIGNING_BGN / 2, after: SIGNING_BGN * 5, publishedAt: '2026-06-10' }],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const flags = flagsByUnp(dbPath);
+ for (const unp of ['UNP-STEP30', 'UNP-EDGE']) {
+ expect(flags.get(unp)?.value_flag, `${unp} should be annex_suspect`).toBe(
+ 'annex_suspect',
+ );
+ // Fell back to the signed value (100 000 EUR), not served at the inflated current value.
+ expect(flags.get(unp)?.amount_eur, `${unp} repaired amount`).toBe(100_000);
+ }
+ });
+ });
+
+ it(`${label}: never flags a chain whose later annex corrects the typo back down`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // The corpus counter-example class: a huge step UP, then a correction that lands the
+ // aggregate BELOW signing. Flagging would replace 50 000 EUR with 100 000 EUR - the
+ // repair itself would inflate the contract.
+ {
+ unp: 'UNP-CORRECTED',
+ steps: [
+ { before: 5, after: SIGNING_BGN, publishedAt: '2026-06-10' },
+ { before: SIGNING_BGN, after: SIGNING_BGN / 2, publishedAt: '2026-06-20' },
+ ],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const flags = flagsByUnp(dbPath);
+ expect(flags.get('UNP-CORRECTED')?.value_flag).toBe('ok');
+ // Served at the corrected current value - half the signing value.
+ expect(flags.get('UNP-CORRECTED')?.amount_eur).toBe(50_000);
+ });
+ });
+
+ it(`${label}: needs BOTH halves - a big step under 5x aggregate, and a slow climb without one`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // Step 49x, but the aggregate stops at 4.9x signing - under the 5x floor.
+ {
+ unp: 'UNP-AGG49',
+ steps: [
+ { before: SIGNING_BGN / 10, after: SIGNING_BGN * 4.9, publishedAt: '2026-06-10' },
+ ],
+ },
+ // Aggregate 8x through three doublings - no single step comes near 10x.
+ {
+ unp: 'UNP-SLOW8',
+ steps: [
+ { before: SIGNING_BGN, after: SIGNING_BGN * 2, publishedAt: '2026-06-10' },
+ { before: SIGNING_BGN * 2, after: SIGNING_BGN * 4, publishedAt: '2026-06-20' },
+ { before: SIGNING_BGN * 4, after: SIGNING_BGN * 8, publishedAt: '2026-06-30' },
+ ],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const flags = flagsByUnp(dbPath);
+ for (const unp of ['UNP-AGG49', 'UNP-SLOW8']) {
+ expect(flags.get(unp)?.value_flag, `${unp} must not be annex_suspect`).toBe('ok');
+ }
+ // Both keep their as-recorded current value.
+ expect(flags.get('UNP-AGG49')?.amount_eur).toBe(490_000);
+ expect(flags.get('UNP-SLOW8')?.amount_eur).toBe(800_000);
+ });
+ });
+
+ it(`${label}: the plain >=100x aggregate still flags even with no usable step`, () => {
+ withEtlDb(label, (dbPath) => {
+ const cases: Case[] = [
+ // value_before missing on the only annex, so the step half can't fire - the original
+ // aggregate rule must still catch the 150x blow-up on its own.
+ {
+ unp: 'UNP-AGG150',
+ steps: [{ before: null, after: SIGNING_BGN * 150, publishedAt: '2026-06-10' }],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of scriptPaths) readScript(dbPath, p);
+
+ const flags = flagsByUnp(dbPath);
+ expect(flags.get('UNP-AGG150')?.value_flag).toBe('annex_suspect');
+ expect(flags.get('UNP-AGG150')?.amount_eur).toBe(100_000);
+ });
+ });
+ }
+
+ it('refresh-slice: the CLI slice path (no external promote) lands the same flags', () => {
+ // scripts/import.mjs runSliceDerive runs derive-amendments + refresh-slice WITHOUT
+ // promote-amendments. That still works because refresh-slice promotes the window's amendments
+ // itself (INSERT OR REPLACE INTO amendments) before its reconciliation pass, which is therefore
+ // authoritative for annex flags on this path too - this run pins exactly that pipeline shape.
+ withEtlDb('no-promote', (dbPath) => {
+ const cases: Case[] = [
+ {
+ unp: 'UNP-STEP30',
+ steps: [{ before: SIGNING_BGN, after: SIGNING_BGN * 30, publishedAt: '2026-06-10' }],
+ },
+ {
+ unp: 'UNP-CORRECTED',
+ steps: [
+ { before: 5, after: SIGNING_BGN, publishedAt: '2026-06-10' },
+ { before: SIGNING_BGN, after: SIGNING_BGN / 2, publishedAt: '2026-06-20' },
+ ],
+ },
+ ];
+ seedContracts(dbPath, cases);
+ seedAmendments(dbPath, cases);
+ for (const p of [derivePath, resolve(root, 'scripts/refresh-slice.sql')])
+ readScript(dbPath, p);
+
+ const flags = flagsByUnp(dbPath);
+ expect(flags.get('UNP-STEP30')?.value_flag).toBe('annex_suspect');
+ expect(flags.get('UNP-STEP30')?.amount_eur).toBe(100_000);
+ expect(flags.get('UNP-CORRECTED')?.value_flag).toBe('ok');
+ expect(flags.get('UNP-CORRECTED')?.amount_eur).toBe(50_000);
+ });
+ });
+
+ it('refresh-slice: re-flags a served contract when the annex arrives in a LATER window', () => {
+ // The production shape of this bug class: the contract clears one refresh window as 'ok', the
+ // mis-keyed annex lands in the next. The reconciliation pass matches the contract through
+ // raw_amendments alone, re-rolls current_value from the served amendments table, and its
+ // re-flag guard must let the contract through to be re-classified - a guard still keyed to the
+ // old >=100x rule would keep it 'ok' forever.
+ const [, refreshScripts] = etlRuns[1]!;
+ withEtlDb('two-window', (dbPath) => {
+ const cases: Case[] = [
+ {
+ unp: 'UNP-LATE',
+ steps: [{ before: SIGNING_BGN, after: SIGNING_BGN * 30, publishedAt: '2026-07-10' }],
+ },
+ ];
+ // Window 1: the contract alone.
+ seedContracts(dbPath, cases);
+ for (const p of refreshScripts) readScript(dbPath, p);
+ expect(flagsByUnp(dbPath).get('UNP-LATE')?.value_flag).toBe('ok');
+
+ // Window 2: only the annex is in the transient staging.
+ sqlite(dbPath, 'DELETE FROM raw_contracts; DELETE FROM raw_tenders;');
+ seedAmendments(dbPath, cases);
+ for (const p of refreshScripts) readScript(dbPath, p);
+
+ const row = flagsByUnp(dbPath).get('UNP-LATE');
+ expect(row?.value_flag).toBe('annex_suspect');
+ expect(row?.amount_eur).toBe(100_000);
+ });
+ });
+});
diff --git a/packages/db/src/value-flag-lot-stotinki-sql.test.ts b/packages/db/src/value-flag-lot-stotinki-sql.test.ts
new file mode 100644
index 00000000..67fea352
--- /dev/null
+++ b/packages/db/src/value-flag-lot-stotinki-sql.test.ts
@@ -0,0 +1,327 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// Issue #247, second reported contract. The стотинки band added in #298 measures against the PROCEDURE
+// estimate, so it only fires when the whole procedure is one lot. On a multi-lot procedure the dropped
+// decimal point still lands at exactly 100x the LOT's own estimate, while the ratio to the procedure
+// total lands wherever that lot's share puts it - 00621-2020-0008 sits at 89.8x and is served at
+// 14,212,416 EUR instead of ~142,000 EUR.
+//
+// The own-row estimate CANNOT be used on its own: docs/etl.md is explicit that for framework and
+// unit-price procedures (medicines, fuel) it is a UNIT price, and a whole call-off legitimately dwarfs
+// it. So the arm is a conjunction - 95x..105x of the own estimate AND at least 10x the procedure
+// estimate, the threshold that already means "implausibly large for this procedure". A unit-price
+// call-off sits at ~1x the procedure estimate and is spared.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
+const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// The амендмент columns refresh-slice.sql/promote-amendments.sql now write into served `amendments`
+// (#305 value_restated/value_treatment/value_suspect, #306 contract_number_raw/link_method). Without
+// them sqlite3 aborts on the amendment promotion long before it reaches the value_flag CASE under test.
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+// #279/ADR-0033: refresh-slice.sql's свързани-лица block reads interest_link_evidence, so 0009 too.
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
+const etlPaths = [
+ ['normalize-raw', resolve(root, 'scripts/normalize-raw.sql')],
+ ['refresh-slice', resolve(root, 'scripts/refresh-slice.sql')],
+] as const;
+
+function sqlite(dbPath: string, sql: string): string {
+ return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', ['-bail', dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function withEtlDb(label: string, run: (dbPath: string) => void): void {
+ const dir = mkdtempSync(resolve(tmpdir(), `sigma-lotband-${label}-`));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ readScript(dbPath, schemaPath);
+ readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, stagingPath);
+ run(dbPath);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+/** The lot's own estimate: 195_583 BGN = exactly 100_000 EUR, so the multiples read directly. */
+const OWN_BGN = 195_583;
+const AUTH_EIK = '000695114';
+const BIDDER_EIK = '831646048';
+
+interface Case {
+ unp: string;
+ /** Contract value, in BGN. */
+ valueBgn: number;
+ /** The lot's own estimate on the contract row, in BGN. `null` = the row carries none. */
+ ownEstBgn: number | null;
+ /** The whole procedure's estimate, in BGN. */
+ procEstBgn: number;
+ /** Currency the row's OWN estimate is denominated in (`procurement_currency`). Defaults to BGN. */
+ ownEstCurrency?: string;
+}
+
+function seed(dbPath: string, cases: Case[]): void {
+ const tenders = cases
+ .map(
+ (c) =>
+ `('eop:tenders:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'public', ${c.procEstBgn}, 'BGN')`,
+ )
+ .join(',\n');
+ const contracts = cases
+ .map(
+ (c) =>
+ `('eop:contracts:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'C-${c.unp}', '2026-06-01', ${c.valueBgn}, 'BGN', ${c.ownEstBgn ?? 'NULL'}, '${c.ownEstCurrency ?? 'BGN'}', '${BIDDER_EIK}', 'Тестов изпълнител')`,
+ )
+ .join(',\n');
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, fetched_at, unp, authority_eik, authority_name, authority_type, estimated_value, currency)
+ VALUES ${tenders};
+
+ INSERT INTO raw_contracts
+ (source, fetched_at, unp, authority_eik, authority_name, contract_number,
+ contract_date, signing_value, currency, estimated_value, procurement_currency,
+ contractor_eik, contractor_name)
+ VALUES ${contracts};`,
+ );
+}
+
+/**
+ * An annex on the contract. With `afterBgn` omitted it is a no-op (before = after), which leaves
+ * `current_value` on the signing value and `eff_eur` unchanged; its only job then is to make the row
+ * visible to refresh-slice.sql's reconciliation pass, whose `contract_base` requires the contract to
+ * HAVE an amendment. With a raised `afterBgn` it also trips the annex condition that makes that pass
+ * actually recompute the flag rather than keep the one the INSERT path assigned.
+ */
+function seedAnnex(dbPath: string, unp: string, beforeBgn: number, afterBgn = beforeBgn): void {
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_amendments
+ (source, dataset_year, dataset_variant, fetched_at, seq_no, document_number, contract_number,
+ contract_date, published_at, unp, authority_eik, authority_name, value_before, value_after,
+ value_delta, currency, contractor_eik, description)
+ VALUES
+ ('eop:annexes:${unp}', 2026, 'eop', '2026-06-08T00:00:00Z', '1', 'AMD-${unp}', 'C-${unp}',
+ '2026-06-01', '2026-06-09', '${unp}', '${AUTH_EIK}', 'Тестов възложител', ${beforeBgn},
+ ${afterBgn}, ${afterBgn - beforeBgn}, 'BGN', '${BIDDER_EIK}', 'Изменение');`,
+ );
+}
+
+interface Row {
+ id: string;
+ value_flag: string;
+ amount_eur: number | null;
+}
+
+const flagsByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT id, value_flag, ROUND(amount_eur) AS amount_eur FROM contracts`,
+ );
+ const out = new Map();
+ for (const r of rows) out.set(r.id.split(':')[2]!, r);
+ return out;
+};
+
+describe('стотинки band measured against the lot estimate (issue #247)', () => {
+ for (const [label, scriptPath] of etlPaths) {
+ it(`${label}: catches the reported multi-lot case the procedure band misses`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // The shape of 00621-2020-0008: exactly 100x the lot estimate, but only 89.8x the procedure
+ // estimate, so the #298 band (which measures against the procedure) never sees it.
+ {
+ unp: 'UNP-LOT100',
+ valueBgn: OWN_BGN * 100,
+ ownEstBgn: OWN_BGN,
+ procEstBgn: Math.round(OWN_BGN * (100 / 89.8)),
+ },
+ ]);
+ readScript(dbPath, scriptPath);
+ const row = flagsByUnp(dbPath).get('UNP-LOT100');
+ expect(row, 'the seeded contract must reach the served table').toBeDefined();
+ expect(row?.value_flag).toBe('value_suspect');
+ // Repaired to the procedure estimate, the established anchor for value_suspect.
+ expect(row?.amount_eur).toBe(111_358);
+ });
+ });
+
+ it(`${label}: spares a unit-price call-off at 100x its own per-unit estimate`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // A framework/unit-price procedure: the row estimate is a UNIT price, the call-off is 100x it
+ // in absolute terms but only a fifth of the procedure ceiling. Repairing this would DESTROY a
+ // real contract - it must stay untouched.
+ {
+ unp: 'UNP-UNIT',
+ valueBgn: OWN_BGN * 100,
+ ownEstBgn: OWN_BGN,
+ procEstBgn: OWN_BGN * 500,
+ },
+ ]);
+ readScript(dbPath, scriptPath);
+ const row = flagsByUnp(dbPath).get('UNP-UNIT');
+ // Without this the `.not.toBe` below passes vacuously on an unseeded row (review cefothe #1).
+ expect(row, 'the seeded contract must reach the served table').toBeDefined();
+ expect(row?.value_flag).not.toBe('value_suspect');
+ // Served at face value: 100 x 100_000 EUR.
+ expect(row?.amount_eur).toBe(10_000_000);
+ });
+ });
+
+ it(`${label}: pins the 95x/105x edges of the band`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // Exactly on each edge — both inside, because the band is inclusive on purpose.
+ { unp: 'UNP-AT95', valueBgn: OWN_BGN * 95, ownEstBgn: OWN_BGN, procEstBgn: OWN_BGN * 9 },
+ {
+ unp: 'UNP-AT105',
+ valueBgn: OWN_BGN * 105,
+ ownEstBgn: OWN_BGN,
+ procEstBgn: OWN_BGN * 10,
+ },
+ // One step outside each edge — a dropped decimal lands AT ~100x, never at 94x or 106x, so
+ // these are ordinary large contracts and must keep their money.
+ { unp: 'UNP-AT94', valueBgn: OWN_BGN * 94, ownEstBgn: OWN_BGN, procEstBgn: OWN_BGN * 9 },
+ {
+ unp: 'UNP-AT106',
+ valueBgn: OWN_BGN * 106,
+ ownEstBgn: OWN_BGN,
+ procEstBgn: OWN_BGN * 10,
+ },
+ ]);
+ readScript(dbPath, scriptPath);
+ const flags = flagsByUnp(dbPath);
+ for (const unp of ['UNP-AT95', 'UNP-AT105', 'UNP-AT94', 'UNP-AT106'])
+ expect(flags.get(unp), `seeded contract ${unp} missing`).toBeDefined();
+ expect(flags.get('UNP-AT95')?.value_flag).toBe('value_suspect');
+ expect(flags.get('UNP-AT105')?.value_flag).toBe('value_suspect');
+ expect(flags.get('UNP-AT94')?.value_flag).not.toBe('value_suspect');
+ expect(flags.get('UNP-AT106')?.value_flag).not.toBe('value_suspect');
+ expect(flags.get('UNP-AT94')?.amount_eur).toBe(9_400_000);
+ expect(flags.get('UNP-AT106')?.amount_eur).toBe(10_600_000);
+ });
+ });
+
+ it(`${label}: pins the 1000 EUR floors on both estimates`, () => {
+ withEtlDb(label, (dbPath) => {
+ const TINY = 1_000; // ≈ 511 EUR — under the floor
+ seed(dbPath, [
+ // Own estimate under 1000 EUR: at these sizes a 100x ratio is noise, not a dropped decimal.
+ { unp: 'UNP-TINYOWN', valueBgn: TINY * 100, ownEstBgn: TINY, procEstBgn: TINY * 10 },
+ // Procedure estimate under 1000 EUR: same reasoning on the other half of the conjunction.
+ { unp: 'UNP-TINYPROC', valueBgn: OWN_BGN * 100, ownEstBgn: OWN_BGN, procEstBgn: TINY },
+ ]);
+ readScript(dbPath, scriptPath);
+ const flags = flagsByUnp(dbPath);
+ expect(flags.get('UNP-TINYOWN'), 'seeded contract missing').toBeDefined();
+ expect(flags.get('UNP-TINYPROC'), 'seeded contract missing').toBeDefined();
+ expect(flags.get('UNP-TINYOWN')?.value_flag).not.toBe('value_suspect');
+ expect(flags.get('UNP-TINYPROC')?.value_flag).not.toBe('value_suspect');
+ });
+ });
+
+ it(`${label}: a row with NO own estimate is not caught by the own-row arm`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // No own estimate at all. The own-row arm has nothing to measure against and must not fire;
+ // the procedure-level band (#298) is the only thing that could still speak here, and at 10x
+ // the procedure estimate it does not. This is the one place the INSERT and the UPDATE path
+ // could disagree, because the reconciliation pass reads the estimate from a different column.
+ { unp: 'UNP-NOOWN', valueBgn: OWN_BGN * 100, ownEstBgn: null, procEstBgn: OWN_BGN * 10 },
+ ]);
+ seedAnnex(dbPath, 'UNP-NOOWN', OWN_BGN * 100);
+ readScript(dbPath, scriptPath);
+ const row = flagsByUnp(dbPath).get('UNP-NOOWN');
+ expect(row, 'the seeded contract must reach the served table').toBeDefined();
+ expect(row?.value_flag).not.toBe('value_suspect');
+ expect(row?.amount_eur).toBe(10_000_000);
+ });
+ });
+
+ it(`${label}: pins the procedure-level floor of the conjunction at 10x`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // Exactly at the floor: 100x the lot estimate and exactly 10x the procedure estimate.
+ {
+ unp: 'UNP-AT10',
+ valueBgn: OWN_BGN * 100,
+ ownEstBgn: OWN_BGN,
+ procEstBgn: OWN_BGN * 10,
+ },
+ // Just under it: 100x the lot estimate but only ~8.9x the procedure estimate.
+ {
+ unp: 'UNP-UNDER10',
+ valueBgn: OWN_BGN * 100,
+ ownEstBgn: OWN_BGN,
+ procEstBgn: Math.round(OWN_BGN * 11.2),
+ },
+ ]);
+ readScript(dbPath, scriptPath);
+ const flags = flagsByUnp(dbPath);
+ expect(flags.get('UNP-AT10'), 'seeded contract missing').toBeDefined();
+ expect(flags.get('UNP-UNDER10'), 'seeded contract missing').toBeDefined();
+ expect(flags.get('UNP-AT10')?.value_flag).toBe('value_suspect');
+ expect(flags.get('UNP-UNDER10')?.value_flag).not.toBe('value_suspect');
+ // Served at face value — the spared row keeps its money, which is the thing that matters.
+ expect(flags.get('UNP-UNDER10')?.amount_eur).toBe(10_000_000);
+ });
+ });
+ }
+
+ // refresh-slice only. The reconciliation pass keeps whatever flag the INSERT path computed unless the
+ // contract ALSO trips an annex condition — so that is the only shape in which its own copy of the band
+ // decides anything, and the only way to pin it. (review cefothe #4/#5)
+ it(`refresh-slice reconciliation: converts the own estimate through ITS currency, not the contract's`, () => {
+ withEtlDb('reconcile-currency', (dbPath) => {
+ const AFTER_BGN = OWN_BGN * 100; // 10 000 000 EUR
+ const SIGNING_BGN = 100_000; // an annex ratio ≥ 100x, which forces the recompute
+ seed(dbPath, [
+ {
+ unp: 'UNP-RECCUR',
+ valueBgn: SIGNING_BGN,
+ ownEstBgn: 100_000, // …denominated in EUR: the contract value is exactly 100x it
+ ownEstCurrency: 'EUR',
+ procEstBgn: 977_915, // 500 000 EUR, so the 10x procedure floor is cleared
+ },
+ ]);
+ seedAnnex(dbPath, 'UNP-RECCUR', SIGNING_BGN, AFTER_BGN);
+ readScript(dbPath, resolve(root, 'scripts/refresh-slice.sql'));
+ const row = flagsByUnp(dbPath).get('UNP-RECCUR');
+ expect(row, 'the seeded contract must reach the served table').toBeDefined();
+ // Converting the estimate with the CONTRACT's currency divides an already-EUR figure by 1.95583,
+ // lands at ~196x instead of 100x, drops out of the band — and the row falls through to
+ // annex_suspect, which repairs to the signing value: 51 129 EUR shown instead of 500 000.
+ expect(row?.value_flag).toBe('value_suspect');
+ expect(row?.amount_eur).toBe(500_000);
+ });
+ });
+});
diff --git a/packages/db/src/value-flag-stotinki-sql.test.ts b/packages/db/src/value-flag-stotinki-sql.test.ts
new file mode 100644
index 00000000..49ab5e6f
--- /dev/null
+++ b/packages/db/src/value-flag-stotinki-sql.test.ts
@@ -0,0 +1,179 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+// The стотинки band (#247): a value entered in cents rather than leva lands at almost exactly 100x the
+// procedure estimate, well under the 200x threshold that value_suspect used to need, so it was served
+// at face value. Measured on the real corpus the ratios form an isolated cluster - 13 contracts between
+// 95x and 105x, two between 85x and 95x, and NOTHING between 105x and 200x - which is why the band is
+// narrow rather than a lowered multiplier.
+//
+// Both derive paths carry their own copy of the flag CASE (two in normalize-raw, three in
+// refresh-slice), so the rule is exercised through the REAL scripts on a real SQLite database, once per
+// path. A copy left behind fails here rather than in production.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
+const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3Path = resolve(root, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// refresh-slice.sql's свързани-лица block reads interest_link_evidence (#279, ADR-0033), so the script
+// only parses against a DB that also has 0006 — without it sqlite3 aborts on „no such table" before it
+// ever reaches the value_flag CASE these tests are about.
+const migration9Path = resolve(root, 'packages/db/migrations/0009_interest_link_evidence.sql');
+// #305 Tier-2: served amendments gained value_restated/value_treatment (promote + refresh-slice write them).
+const migration6Path = resolve(root, 'packages/db/migrations/0006_amendment_restated.sql');
+const migration7Path = resolve(root, 'packages/db/migrations/0007_amendment_value_suspect.sql');
+// #306 provenance columns on served `amendments` — promote/refresh-slice write contract_number_raw + link_method.
+const migration8Path = resolve(root, 'packages/db/migrations/0008_amendment_provenance.sql');
+const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
+const etlPaths = [
+ ['normalize-raw', resolve(root, 'scripts/normalize-raw.sql')],
+ ['refresh-slice', resolve(root, 'scripts/refresh-slice.sql')],
+] as const;
+
+function sqlite(dbPath: string, sql: string): string {
+ return execFileSync('sqlite3', [dbPath], { input: sql, encoding: 'utf8' });
+}
+
+function sqliteJson(dbPath: string, sql: string): T[] {
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql], { encoding: 'utf8' }).trim();
+ return out ? (JSON.parse(out) as T[]) : [];
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', ['-bail', dbPath], {
+ input: `PRAGMA foreign_keys=ON;\n.read ${path}\n`,
+ stdio: 'pipe',
+ });
+}
+
+function withEtlDb(label: string, run: (dbPath: string) => void): void {
+ const dir = mkdtempSync(resolve(tmpdir(), `sigma-stotinki-${label}-`));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ readScript(dbPath, schemaPath);
+ readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
+ readScript(dbPath, migration9Path);
+ readScript(dbPath, migration6Path);
+ readScript(dbPath, migration7Path);
+ readScript(dbPath, migration8Path);
+ readScript(dbPath, stagingPath);
+ run(dbPath);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+/** Estimate in BGN; the EUR estimate is this ÷ 1.95583. Contract values below are in BGN too. */
+const ESTIMATE_BGN = 195_583; // exactly 100_000 EUR, so the multiples below are easy to read
+const AUTH_EIK = '000695114';
+const BIDDER_EIK = '831646048';
+
+/** One tender + one contract per case, so each case gets its own procedure estimate. */
+function seed(dbPath: string, cases: { unp: string; valueBgn: number }[]): void {
+ const tenders = cases
+ .map(
+ (c) =>
+ `('eop:tenders:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'public', ${ESTIMATE_BGN}, 'BGN')`,
+ )
+ .join(',\n');
+ const contracts = cases
+ .map(
+ (c) =>
+ `('eop:contracts:${c.unp}', '2026-06-01T00:00:00Z', '${c.unp}', '${AUTH_EIK}', 'Тестов възложител', 'C-${c.unp}', '2026-06-01', ${c.valueBgn}, 'BGN', '${BIDDER_EIK}', 'Тестов изпълнител')`,
+ )
+ .join(',\n');
+ sqlite(
+ dbPath,
+ `INSERT INTO raw_tenders
+ (source, fetched_at, unp, authority_eik, authority_name, authority_type, estimated_value, currency)
+ VALUES ${tenders};
+
+ INSERT INTO raw_contracts
+ (source, fetched_at, unp, authority_eik, authority_name, contract_number,
+ contract_date, signing_value, currency, contractor_eik, contractor_name)
+ VALUES ${contracts};`,
+ );
+}
+
+interface Row {
+ id: string;
+ value_flag: string;
+ amount_eur: number | null;
+}
+
+const flagsByUnp = (dbPath: string): Map => {
+ const rows = sqliteJson(
+ dbPath,
+ `SELECT id, value_flag, ROUND(amount_eur) AS amount_eur FROM contracts`,
+ );
+ const out = new Map();
+ for (const r of rows) {
+ const unp = r.id.split(':')[2]!;
+ out.set(unp, r);
+ }
+ return out;
+};
+
+describe('стотинки band in the value_flag CASE', () => {
+ for (const [label, scriptPath] of etlPaths) {
+ it(`${label}: flags a value at ~100x the estimate and repairs it to the estimate`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // Exactly 100x: the signature of a value typed in стотинки.
+ { unp: 'UNP-X100', valueBgn: ESTIMATE_BGN * 100 },
+ // Inside the band but not exactly 100x: the стотинки value of a contract whose own price came
+ // in slightly under the procedure estimate, so the ratio lands a little below the multiple.
+ { unp: 'UNP-X97', valueBgn: Math.round(ESTIMATE_BGN * 97) },
+ ]);
+ readScript(dbPath, scriptPath);
+
+ const flags = flagsByUnp(dbPath);
+ for (const unp of ['UNP-X100', 'UNP-X97']) {
+ expect(flags.get(unp)?.value_flag, `${unp} should be value_suspect`).toBe(
+ 'value_suspect',
+ );
+ // Repaired to the procedure estimate (100 000 EUR), not served at 100x.
+ expect(flags.get(unp)?.amount_eur, `${unp} repaired amount`).toBe(100_000);
+ }
+ });
+ });
+
+ it(`${label}: leaves ordinary overruns and the gap above the band alone`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [
+ // A real overrun: flagged 'review' since 2020, but the value is kept.
+ { unp: 'UNP-X20', valueBgn: ESTIMATE_BGN * 20 },
+ // Just above the band. The corpus has nothing between 105x and 200x; if a case ever appears
+ // there it must NOT be silently repaired to the estimate.
+ { unp: 'UNP-X120', valueBgn: ESTIMATE_BGN * 120 },
+ // Just below the band, where the two real 85x-95x contracts live.
+ { unp: 'UNP-X90', valueBgn: ESTIMATE_BGN * 90 },
+ ]);
+ readScript(dbPath, scriptPath);
+
+ const flags = flagsByUnp(dbPath);
+ for (const unp of ['UNP-X20', 'UNP-X120', 'UNP-X90']) {
+ expect(flags.get(unp)?.value_flag, `${unp} must not be value_suspect`).toBe('review');
+ // Kept at face value, which is the whole point of 'review' as opposed to 'value_suspect'.
+ expect(flags.get(unp)?.amount_eur, `${unp} keeps its value`).toBeGreaterThan(1_000_000);
+ }
+ });
+ });
+
+ it(`${label}: still catches the plain over-200x case it always did`, () => {
+ withEtlDb(label, (dbPath) => {
+ seed(dbPath, [{ unp: 'UNP-X500', valueBgn: ESTIMATE_BGN * 500 }]);
+ readScript(dbPath, scriptPath);
+ const flags = flagsByUnp(dbPath);
+ expect(flags.get('UNP-X500')?.value_flag).toBe('value_suspect');
+ expect(flags.get('UNP-X500')?.amount_eur).toBe(100_000);
+ });
+ });
+ }
+});
diff --git a/packages/ingest/src/amendment-total.test.ts b/packages/ingest/src/amendment-total.test.ts
new file mode 100644
index 00000000..ff1f9cef
--- /dev/null
+++ b/packages/ingest/src/amendment-total.test.ts
@@ -0,0 +1,348 @@
+// #305 — the value-double-count text heuristic, tested against REAL основание text pulled from the live
+// corpus (contracts 145652, 189325, 84818, 108677, 79382, 113291, 103903). The hazard is false positives,
+// so the controls (genuine increment, uncorrectable, normal increase) matter as much as the hits.
+import { describe, expect, it } from 'vitest';
+import {
+ classifyAmendmentValue,
+ restatedValueAfter,
+ isGenuineIncrement,
+ type AmendmentValueInput,
+} from './amendment-total';
+
+const mk = (
+ valueBefore: number,
+ valueAfter: number,
+ valueDelta: number,
+ currency: string,
+ ...texts: string[]
+): AmendmentValueInput => ({ valueBefore, valueAfter, valueDelta, currency, texts });
+
+describe('#305 amendment value double-count heuristic', () => {
+ it('restates a total announced as "…на " (145652)', () => {
+ const t = classifyAmendmentValue(
+ mk(
+ 442000,
+ 981240,
+ 539240,
+ 'BGN',
+ 'относно актуализиране стойността на договора … общата стойност на договора ще възлезе на 539 240.00 лв. без ДДС',
+ ),
+ );
+ expect(t).toEqual({ kind: 'total_restated', correctedAfter: 539240 });
+ });
+
+ it('restates a total announced as "…от X на " (79382, 113291 family)', () => {
+ expect(
+ restatedValueAfter(
+ mk(
+ 197720,
+ 484414,
+ 286694,
+ 'BGN',
+ 'Общата стойност на договор № 447 се променя от 197 720.00 лева без ДДС на 286 694,00 (двеста осемдесет и шест хиляди) лева без ДДС',
+ ),
+ ),
+ ).toBe(286694);
+ expect(
+ restatedValueAfter(
+ mk(
+ 13662405.12,
+ 28356190.64,
+ 14693785.52,
+ 'BGN',
+ 'в чл. 7 (1) от Договора общата цена за изпълнение предмета на договора се променя от 13 662 405,12 лв. без ДДС на 14 693 785,52 лв. без ДДС',
+ ),
+ ),
+ ).toBe(14693785.52);
+ });
+
+ it('does NOT restate "в размер на / ресурс / increment" phrasings — they name the change, not the total', () => {
+ // Real corpus 271148→650754: "…максималния ресурс за изменението в размер на 379 606.50" names the
+ // INCREMENT; restating value_after to it would understate a genuine >100% increase. Must be `none`.
+ expect(
+ classifyAmendmentValue(
+ mk(
+ 271147.5,
+ 650754,
+ 379606.5,
+ 'BGN',
+ 'Срокът се удължава до изчерпване на максималния ресурс за изменението в размер на 379 606.50 лв. без ДДС',
+ ),
+ ).kind,
+ ).toBe('none');
+ // "…или сума в размер на 86 363.08" — the added-work amount, not the new contract total.
+ expect(
+ restatedValueAfter(
+ mk(
+ 71969.23,
+ 151662.82,
+ 79693.59,
+ 'BGN',
+ 'Общата стойност на договорените СМР се променя от 71 969.23 без ДДС или сума в размер на 79 693.59 лв.',
+ ),
+ ),
+ ).toBeNull();
+ });
+
+ it('restates a currency re-denomination that doubled an unchanged total (189325)', () => {
+ const t = classifyAmendmentValue(
+ mk(
+ 77000000,
+ 154000000,
+ 77000000,
+ 'BGN',
+ 'Считано от 01.10.2025 г., отпечатваната върху акцизните бандероли продажна цена се променя от лева в евро.',
+ ),
+ );
+ expect(t).toEqual({ kind: 'unchanged_restated', correctedAfter: 77000000 });
+ });
+
+ it('does NOT touch a genuine increment announced as "…с " (108677)', () => {
+ const input = mk(
+ 10226.85,
+ 60226.85,
+ 50000,
+ 'EUR',
+ 'Увеличава се финансовият ресурс на договор № АО – 05 – 168 с 50 000 /петдесет хиляди/ евро без ДДС.',
+ );
+ expect(isGenuineIncrement(input)).toBe(true);
+ expect(restatedValueAfter(input)).toBeNull();
+ });
+
+ it('does NOT restate an exact-2× when the text announces a DIFFERENT total ("до 18 900") — flag, not rewrite (103903)', () => {
+ // #307: exact 2× (delta 15 120 = value_before) BUT the text says the value rose "до 18 900" — it is NOT
+ // unchanged. Neither the doubled 30 240 nor the halved 15 120 is the true total, and 18 900 is not
+ // recoverable here, so return `none` and let the arithmetic annex_total_suspect flag exclude the row.
+ const t = classifyAmendmentValue(
+ mk(
+ 15120,
+ 30240,
+ 15120,
+ 'BGN',
+ 'Прогнозната стойност по договора се увеличава от 15 120 лв. без ДДС до 18 900 лв. без ДДС',
+ ),
+ );
+ expect(t).toEqual({ kind: 'none' });
+ });
+
+ it('does NOT text-freely restate an exact-2× when the основание carries no restatement signal (84818)', () => {
+ // #307: restructuring note with no value/unchanged signal. A text-free halving could erase a legitimate
+ // ЗОП чл.116 ал.1 т.1 in-scope +100% (pre-announced option clause), so it must fall to the arithmetic
+ // annex_total_suspect flag (exclude), not be rewritten to the before-value.
+ const t = classifyAmendmentValue(
+ mk(
+ 76769540.87,
+ 153539081.74,
+ 76769540.87,
+ 'EUR',
+ 'Следните курсове за 22 пилота се преструктурират и се изпълняват в рамките на гаранционния период',
+ ),
+ );
+ expect(t).toEqual({ kind: 'none' });
+ });
+
+ it('restates an exact-2× administrative annex (non-value change) to the before-value', () => {
+ const t = classifyAmendmentValue(
+ mk(
+ 2685,
+ 5370,
+ 2685,
+ 'BGN',
+ 'Променя се упълномощеното лице по договора. Несъществени промени.',
+ ),
+ );
+ expect(t).toEqual({ kind: 'unchanged_restated', correctedAfter: 2685 });
+ });
+
+ it('does NOT text-freely restate an exact-2× on an outside-ЗОП exception contract', () => {
+ // ЗОП чл.116's +50% cap does not bind exception contracts, so an exact +100% there can be a genuine
+ // increase — the text-free rule 3 must stand down and let the arithmetic flag exclude (not rewrite) it.
+ const base = mk(
+ 2685,
+ 5370,
+ 2685,
+ 'BGN',
+ 'Променя се упълномощеното лице по договора. Несъществени промени.',
+ );
+ expect(classifyAmendmentValue({ ...base, outsideZop: true }).kind).toBe('none');
+ // …but the same row in-scope of ЗОП is still restated (guard is scoped to rule 3 only).
+ expect(classifyAmendmentValue({ ...base, outsideZop: false })).toEqual({
+ kind: 'unchanged_restated',
+ correctedAfter: 2685,
+ });
+ });
+
+ it('still applies the text-confirmed rules on an outside-ЗОП contract', () => {
+ // The double-count is a feed defect independent of ЗОП scope, so a text-confirmed total is corrected
+ // even for an exception contract — only the text-free exact-2× fallback is gated by outsideZop.
+ const total = {
+ ...mk(
+ 442000,
+ 981240,
+ 539240,
+ 'BGN',
+ 'общата стойност на договора ще възлезе на 539 240.00 лв.',
+ ),
+ outsideZop: true,
+ };
+ expect(classifyAmendmentValue(total)).toEqual({
+ kind: 'total_restated',
+ correctedAfter: 539240,
+ });
+ const incr = {
+ ...mk(10226.85, 60226.85, 50000, 'EUR', 'Увеличава се ресурсът с 50 000 евро без ДДС.'),
+ outsideZop: true,
+ };
+ expect(isGenuineIncrement(incr)).toBe(true);
+ });
+
+ it('ignores normal increases (< 2×) and non-self-consistent rows', () => {
+ expect(classifyAmendmentValue(mk(100, 130, 30, 'BGN', 'обща стойност на 130 лв.')).kind).toBe(
+ 'none',
+ );
+ // a ≠ b + d ⇒ the double-count model does not apply
+ expect(classifyAmendmentValue(mk(100, 250, 100, 'BGN', 'обща стойност на 100')).kind).toBe(
+ 'none',
+ );
+ });
+
+ it('requires the text figure to actually equal the delta (no coincidental match)', () => {
+ // delta 500000 appears nowhere as a total; a different figure 12345 does — must not restate.
+ expect(
+ restatedValueAfter(mk(400000, 900000, 500000, 'BGN', 'обща стойност на 12 345 лв.')),
+ ).toBeNull();
+ });
+
+ it('does NOT restate a bare "…на " over a NON-monetary number (#307 HIGH-1 — days / article nos.)', () => {
+ // "…удължава на 200 дни": 200 is a day count that coincidentally == value_delta. Without a currency
+ // anchor around the figure it must stay `none`, never overwrite the published 300 with 200.
+ expect(
+ classifyAmendmentValue(mk(100, 300, 200, 'BGN', 'Срокът на договора се удължава на 200 дни.'))
+ .kind,
+ ).toBe('none');
+ // An article number after "на" — non-monetary, must not restate.
+ expect(
+ classifyAmendmentValue(
+ mk(100, 300, 200, 'BGN', 'Договорът се изменя на 200 съгласно чл. 116 на ЗОП.'),
+ ).kind,
+ ).toBe('none');
+ });
+
+ it('does NOT anchor a day-count on a currency token elsewhere in the sentence (#307 MONEY_AFTER window)', () => {
+ // "…удължава на 200 дни, стойността остава 100 лв.": 200 is a DAY count; the "лв." belongs to a
+ // different figure downstream. A non-monetary unit right after 200 must veto it, not restate 300→200.
+ expect(
+ classifyAmendmentValue(
+ mk(100, 300, 200, 'BGN', 'Срокът се удължава на 200 дни, стойността остава 100 лв.'),
+ ).kind,
+ ).toBe('none');
+ expect(
+ classifyAmendmentValue(
+ mk(
+ 100,
+ 300,
+ 200,
+ 'BGN',
+ 'Срокът за изпълнение на договора се удължава на 200 дни, без промяна в договорената сума в лв.',
+ ),
+ ).kind,
+ ).toBe('none');
+ });
+
+ it('vetoes the QUALIFIED day/quantity unit, not just the bare word (#307 review — работни/календарни дни class)', () => {
+ // The unit almost never comes bare in real annexes ("работни дни", "календарни дни", "200 (двеста)
+ // дни", "кв.м"). Each of these is a duration/quantity that coincidentally == value_delta; none may
+ // overwrite the published 300 with 200. Tests the error CLASS, not one literal sentence.
+ const dayCounts = [
+ 'Срокът се удължава на 200 работни дни, стойността остава 100 лв.',
+ 'Срокът се удължава на 200 календарни дни, стойността остава 100 лв.',
+ 'Срокът се удължава на 200 работни дни, без промяна в договорената сума в лв.',
+ 'Срокът се удължава на 200 к.д., стойността остава 100 лв.',
+ 'Срокът се удължава на 200 (двеста) дни, стойността остава 100 лв.',
+ 'Срокът се удължава на 200 р.д., стойността остава 100 лв.',
+ 'Площта се увеличава на 200 кв.м, стойността остава 100 лв.',
+ 'Обемът се увеличава на 200 куб.м, стойността остава 100 лв.',
+ ];
+ for (const text of dayCounts) {
+ expect(classifyAmendmentValue(mk(100, 300, 200, 'BGN', text)).kind).toBe('none');
+ }
+ });
+
+ it('the wider unit veto does NOT swallow a real monetary total (#307 review — reverse direction)', () => {
+ // A qualified/adjacent-word unit veto must not fire on genuine money phrasings: the figure still
+ // restates to the announced total. Guards against the veto over-reaching.
+ const realTotals = [
+ 'Общата стойност на договора възлиза на 200 лв. без ДДС.',
+ 'Общата стойност на договора възлиза на 200 лева.',
+ 'Новата обща стойност възлиза на 200 лв. за срок от 12 месеца.',
+ 'Общата стойност се увеличава на 200 лева месечно.',
+ 'Общата стойност възлиза на 200 лв. и срокът се удължава с 30 работни дни.',
+ ];
+ for (const text of realTotals) {
+ expect(restatedValueAfter(mk(100, 300, 200, 'BGN', text))).toBe(200);
+ }
+ });
+
+ it('does NOT restate an exact-2× on a bare payment-in-euro clause (#307 в-евро narrowing)', () => {
+ // "Плащанията…се извършват в евро…" is a payment-currency clause, NOT an unchanged-value signal — it
+ // must not halve a real +100%. Only "X в евро" re-denomination phrasing may restate (see 189325).
+ const t = classifyAmendmentValue(
+ mk(
+ 250000,
+ 500000,
+ 250000,
+ 'BGN',
+ 'Плащанията по договора се извършват в евро по сметка на изпълнителя.',
+ ),
+ );
+ expect(t).toEqual({ kind: 'none' });
+ });
+
+ it('restates a bare "…на " only WHEN a currency unit follows the figure (#307 HIGH-1 anchor)', () => {
+ // Same "…на " shape as the days case, but a currency unit anchors it as money ⇒ genuine total.
+ expect(
+ restatedValueAfter(
+ mk(100, 300, 200, 'BGN', 'Общата стойност на договора се променя на 200 лв. без ДДС.'),
+ ),
+ ).toBe(200);
+ });
+
+ it('does NOT rewrite an exact-2× with empty / whitespace-only texts (#307 HIGH-2 repro)', () => {
+ const t = classifyAmendmentValue({
+ valueBefore: 539240,
+ valueAfter: 1078480,
+ valueDelta: 539240,
+ currency: 'BGN',
+ texts: [null, '', ' '],
+ outsideZop: null,
+ });
+ expect(t).toEqual({ kind: 'none' });
+ });
+
+ it('does NOT rewrite an exact-2× when the text is unrelated to value (#307 HIGH-2 repro)', () => {
+ const t = classifyAmendmentValue({
+ valueBefore: 250000,
+ valueAfter: 500000,
+ valueDelta: 250000,
+ currency: 'BGN',
+ outsideZop: false,
+ texts: ['Смяна на адреса за кореспонденция на изпълнителя.'],
+ });
+ expect(t).toEqual({ kind: 'none' });
+ });
+
+ it('parses a dot-thousands + comma-decimal figure "1.234,56" (#305 number-format recall)', () => {
+ // Mixed-separator total announced as "…на 1.234,56 лв." — the resolver must read 1234.56, not 1.23.
+ expect(
+ restatedValueAfter(
+ mk(700, 1934.56, 1234.56, 'BGN', 'Общата стойност на договора се променя на 1.234,56 лв.'),
+ ),
+ ).toBe(1234.56);
+ // …and the US ordering "1,234.56" resolves to the same value.
+ expect(
+ restatedValueAfter(
+ mk(700, 1934.56, 1234.56, 'BGN', 'Общата стойност на договора се променя на 1,234.56 лв.'),
+ ),
+ ).toBe(1234.56);
+ });
+});
diff --git a/packages/ingest/src/amendment-total.ts b/packages/ingest/src/amendment-total.ts
new file mode 100644
index 00000000..fb5e4495
--- /dev/null
+++ b/packages/ingest/src/amendment-total.ts
@@ -0,0 +1,227 @@
+// #305 — amendment value double-count. ЦАИС ЕОП sometimes puts the announced NEW TOTAL contract value
+// into the "change" field (`contractValueDifference` → `value_delta`), so the feed's
+// `currentContractValue` (→ `value_after`) = `lastContractValue` + newTotal — the value is doubled. The
+// source is internally consistent (`value_after = value_before + value_delta` at ~100% of rows), so the
+// signal is semantic, not arithmetic: is `value_delta` an increment or a total?
+//
+// This module answers that from the основание free text, which the raw feed carries in three fields
+// (changeDescription/changeReason/changeReasonDescription). The discriminator is the Bulgarian preposition
+// in front of the figure: "на " (to N) / "възлиза/става/обща стойност" ⇒ N is a TOTAL; "с " (by N) /
+// "увеличава се … с" ⇒ N is an INCREMENT. See docs/implementation-plans/305-amendment-value-double-count.md.
+//
+// Conservative by design: it only classifies when the text unambiguously confirms; otherwise it returns
+// `none` and leaves the row to the arithmetic `annex_total_suspect` flag (Tier 1). It NEVER rewrites a
+// value it cannot corroborate from text. Note: JS `\b`/`\w` are ASCII-only, so all boundaries/letters use
+// Unicode (`\p{L}`, explicit non-letter boundary) with the `u` flag.
+
+export type AmendmentValueTreatment =
+ // The delta is an announced total; the true value_after is value_delta (double-count corrected).
+ | { kind: 'total_restated'; correctedAfter: number }
+ // An exact 2× (value_delta ≈ value_before): the "difference" field echoed the OLD value, so the value
+ // is unchanged and value_after was doubled onto itself; the true value_after is value_before. Covers
+ // currency re-denominations and non-value administrative annexes alike.
+ | { kind: 'unchanged_restated'; correctedAfter: number }
+ // The delta is a genuine increment already correctly applied — value_after is right; do NOT flag it.
+ | { kind: 'genuine_increment' }
+ // No text signal — leave to the arithmetic flag.
+ | { kind: 'none' };
+
+export interface AmendmentValueInput {
+ valueBefore: number | null;
+ valueAfter: number | null;
+ valueDelta: number | null;
+ currency: string | null;
+ texts: Array;
+ // #305 — the text-free exact-2× rule leans on ЗОП чл.116 (a single amendment caps at +50%, so +100% is a
+ // defect not a real increase). чл.116 does NOT bind contracts procured outside ЗОП (exception contracts),
+ // where a genuine +100% is legal — so for those, only the text-confirmed rules may restate. NULL/false =
+ // in-scope of ЗОП (the safe default: apply the rule).
+ outsideZop?: boolean | null;
+}
+
+const REL_TOL = 0.005; // 0.5% — the text figure must be the SAME number as value_delta, allowing rounding.
+// #305 — capture a full number token that may group thousands with space/nbsp/narrow-nbsp OR with '.'/','
+// (BG "1.234,56", US "1,234.56"); normalizeBgNumber disambiguates the decimal mark below. The token must
+// END on a digit so a trailing sentence period ("…100. Нов срок") is not swallowed into the number.
+const NUMBER_RE = /\d[\d\u0020\u00a0\u202f.,]*\d|\d/g;
+const WS = /[\s ]/g;
+
+// A left boundary: start-of-window or a non-letter, non-digit character (Unicode-aware — Cyrillic is a
+// letter). Keywords that end the "before the figure" window signal how the figure should be read.
+const B = '(?:^|[^\\p{L}\\d])';
+const TOTAL_CTX = new RegExp(
+ `${B}(?:възлиз\\p{L}*|възлез\\p{L}*|става|обща\\p{L}*\\s+(?:стойност|цена)|крайн\\p{L}*\\s+(?:стойност|цена)|нов\\p{L}*\\s+(?:обща\\s+)?(?:стойност|цена)|на)\\s*$`,
+ 'iu',
+);
+// The figure sits right after "от " (the OLD value) or "с/със " (an increment) — not a total.
+const NOT_TOTAL_CTX = new RegExp(`${B}(?:от|с|със)\\s*$`, 'iu');
+// "…с " / "…със " — N is an increment already applied.
+const INCREMENT_CTX = new RegExp(`${B}(?:с|със)\\s*$`, 'iu');
+// A wider veto on the "…на " total match: Bulgarian "в размер на " ("in the amount of N"),
+// "ресурс … в размер на N", "допълнителни … на обща стойност N" name the CHANGE/added-work amount, not
+// the new contract total — restating value_after := N there would be wrong (verified on the real corpus).
+// Checked over a wider window than NOT_TOTAL_CTX because these markers sit a few words before the figure.
+const TOTAL_VETO = /(?:в\s+размер|ресурс\p{L}*|допълнителн\p{L}*|увеличени\p{L}*|намалени\p{L}*)/iu;
+
+// #307 — a total restatement needs a MONETARY anchor bracketing the figure. Bare "на " is not a money
+// signal ("на" also precedes days, article numbers, quantities), so "…удължава на 200 дни" would otherwise
+// rewrite the value with a day count. Accept the figure only when a value keyword sits immediately before
+// it (MONEY_BEFORE) OR a currency unit follows it (MONEY_AFTER). On the real corpus the value keyword is
+// usually far from the figure ("…ще възлезе на 539 240.00 лв."), so the currency unit after the number is
+// the load-bearing anchor. No ASCII \b (Cyrillic).
+const MONEY_BEFORE = /(?:стойност|цена)\p{L}*\s*$/iu;
+const MONEY_AFTER = /(?:^|[^\p{L}])(?:лв\.?|лева|лев|bgn|eur|евро|euro|€|usd|\$)(?![\p{L}])/iu;
+// #307 — MONEY_AFTER scans the whole ~60-char window, so a sentence that names both a term and a value
+// ("…удължава на 200 дни, стойността остава 100 лв.") lets a downstream currency token anchor a figure
+// that is actually a day count. A non-monetary unit sitting IMMEDIATELY after the figure (days, months,
+// years, count, percent) overrides any currency further along: the figure is a duration/quantity, never
+// the contract value. Anchored at ^ against the post-figure slice so only the immediate suffix counts.
+// Real BG annexes almost never write the unit bare — the term is qualified ("работни дни", "календарни
+// дни") — so allow one optional adjective word (and an optional spelled-out number in brackets, "200
+// (двеста) дни") between the figure and the unit, and cover area/volume/weight units too. Errs safe: a
+// false veto only downgrades a row to `none`, dropping it to the arithmetic annex_total_suspect flag
+// rather than publishing a substituted value.
+const NON_MONEY_UNIT_AFTER =
+ /^\s*(?:\([^)]*\)\s*)?(?:\p{L}+\s+)?(?:дни|дн\.|к\.\s?д\.|р\.\s?д\.|месец\p{L}*|години|год\.|броя|бр\.|кв\.?\s?м|куб\.?\s?м|тона|литра|%|процент\p{L}*)/iu;
+
+// #307 — the exact-2× "unchanged" restatement (rule 3) may only fire WITH a positive textual signal that
+// the value did not really change: a currency re-denomination that mechanically doubled the figure, or an
+// explicit "unchanged / non-material" phrasing. Absent any signal the row returns `none` and falls to the
+// arithmetic annex_total_suspect flag (exclude), rather than silently halving a possibly-legitimate
+// ЗОП чл.116 ал.1 т.1 in-scope +100% (a pre-announced option clause `outsideZop` cannot model).
+// #307 — the anchor is the "X в евро" re-denomination phrasing (`лев… в евро`), NOT a bare "в евро": a
+// payment-currency clause ("Плащанията…се извършват в евро…") says nothing about an unchanged total and
+// would silently halve a real doubling. The bare form was also redundant — the 189325 fixture
+// ("…се променя от лева в евро") is already caught by the `лев… в евро` alternative.
+const RESTATE_UNCHANGED_CTX =
+ /(?:лев\p{L}*\s+в\s+евро|деноминаци\p{L}*|не\s*се\s+промен\p{L}*|остава\p{L}*\s+непромен\p{L}*|без\s+промяна|несъществен\p{L}*)/iu;
+
+function normalizeBgNumber(raw: string): number | null {
+ let t = raw.replace(WS, '');
+ // #305 — when BOTH '.' and ',' appear the number uses one as a thousands separator and the other as the
+ // decimal mark (BG "1.234,56" or US "1,234.56"). The LAST-occurring separator is the decimal; strip the
+ // other (thousands) and normalise the decimal to '.'. Single-separator numbers keep the existing
+ // ≤2-fraction-digit convention (the space-thousands corpus: "539 240.00", "286 694,00").
+ if (t.includes('.') && t.includes(',')) {
+ const decimalChar = t.lastIndexOf('.') > t.lastIndexOf(',') ? '.' : ',';
+ const thousandsChar = decimalChar === '.' ? ',' : '.';
+ t = t.split(thousandsChar).join('');
+ if (decimalChar === ',') t = t.replace(',', '.');
+ }
+ const m = t.match(/^(\d+)(?:[.,](\d{1,2}))?$/);
+ if (!m) return null;
+ const value = Number(m[2] ? `${m[1]}.${m[2]}` : m[1]);
+ return Number.isFinite(value) && value > 0 ? value : null;
+}
+
+function approxEq(a: number, b: number): boolean {
+ return Math.abs(a - b) <= REL_TOL * Math.max(Math.abs(a), Math.abs(b));
+}
+
+// Does a figure ≈ `target` occur in `text` with the ~40 preceding chars matching `contextRe` and (when
+// given) NOT matching `excludeRe`? Returns true on the first qualifying occurrence.
+function figureInContext(
+ text: string,
+ target: number,
+ contextRe: RegExp,
+ excludeRe: RegExp | null,
+ wideVetoRe: RegExp | null = null,
+ requireMoneyAnchor = false,
+): boolean {
+ NUMBER_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = NUMBER_RE.exec(text)) !== null) {
+ const n = normalizeBgNumber(m[0]);
+ if (n === null || !approxEq(n, target)) continue;
+ const before = text.slice(Math.max(0, m.index - 40), m.index);
+ if (excludeRe && excludeRe.test(before)) continue;
+ // A wider veto looks further back (~55 chars) for "в размер"/"ресурс"/increment markers that make an
+ // "…на " an amount, not a total.
+ if (wideVetoRe && wideVetoRe.test(text.slice(Math.max(0, m.index - 55), m.index))) continue;
+ if (!contextRe.test(before)) continue;
+ // #307 — a total needs a monetary marker bracketing the figure, else a bare "…на " matches a
+ // non-monetary number (days, article nos.) that coincidentally ≈ the target. A value keyword right
+ // before, OR a currency unit within the ~60 chars after, qualifies.
+ if (requireMoneyAnchor) {
+ const after = text.slice(m.index + m[0].length, m.index + m[0].length + 60);
+ // A non-monetary unit immediately after the figure (days/months/years/count/%) vetoes it before
+ // a downstream currency token can wrongly anchor it as money (#307).
+ if (NON_MONEY_UNIT_AFTER.test(after)) continue;
+ if (!MONEY_BEFORE.test(before) && !MONEY_AFTER.test(after)) continue;
+ }
+ return true;
+ }
+ return false;
+}
+
+export function classifyAmendmentValue(input: AmendmentValueInput): AmendmentValueTreatment {
+ const b = input.valueBefore;
+ const a = input.valueAfter;
+ const d = input.valueDelta;
+ if (b === null || a === null || d === null || b <= 0 || a <= 0 || d <= 0) return { kind: 'none' };
+ // Source self-consistency (a = b + d) is the precondition of the defect model.
+ if (!approxEq(a, b + d)) return { kind: 'none' };
+ // A single annex whose "increment" is at least the whole prior value (2b ≤ a < 10b). Below 2b is a
+ // normal increase; ≥10b is a mis-key handled by #299's annex_suspect.
+ if (a < 2 * b || a >= 10 * b) return { kind: 'none' };
+
+ const text = input.texts.filter((t): t is string => !!t && t.trim() !== '').join(' ');
+
+ // 1) The delta figure appears as an INCREMENT ("с ") — the value is genuinely correct, don't
+ // touch it. Checked FIRST so an exact 2× that the text calls a real increase is not mis-restated.
+ if (text && figureInContext(text, d, INCREMENT_CTX, null)) return { kind: 'genuine_increment' };
+
+ // 2) The delta figure appears as a TOTAL ("на ", "възлиза на …", "обща стойност … ").
+ // The true value_after is the delta (the announced new total). TOTAL_VETO rejects "в размер на"/
+ // "ресурс"/increment phrasings that name the change amount, not the contract total. A monetary anchor
+ // is REQUIRED (#307) so a bare "…на " over a non-monetary number (days, article nos.) is rejected.
+ if (text && figureInContext(text, d, TOTAL_CTX, NOT_TOTAL_CTX, TOTAL_VETO, true)) {
+ return { kind: 'total_restated', correctedAfter: d };
+ }
+
+ // 3) Exact 2× (value_delta ≈ value_before): the "difference" field just echoed the OLD value, so
+ // value_after = before + before double-counts an UNCHANGED value (currency re-denomination or a
+ // non-value administrative annex). This restatement to value_before is only SAFE with a positive text
+ // signal (#307): ЗОП чл.116 ал.1 т.1 permits a genuine in-scope +100% via a pre-announced option/review
+ // clause that `outsideZop` cannot see, so a text-free rewrite could silently HALVE a legitimate value.
+ // Require either a "value unchanged / re-denomination" phrasing (RESTATE_UNCHANGED_CTX) or the
+ // before-value itself announced as the new total. Absent any signal, return `none` and let the
+ // arithmetic annex_total_suspect flag EXCLUDE the row (an honest gap beats a silent corruption).
+ // Still skipped for outside-ЗОП exception contracts, where a real +100% is legal.
+ if (approxEq(a, 2 * b) && !input.outsideZop && text) {
+ if (
+ RESTATE_UNCHANGED_CTX.test(text) ||
+ figureInContext(text, b, TOTAL_CTX, NOT_TOTAL_CTX, TOTAL_VETO, true)
+ ) {
+ return { kind: 'unchanged_restated', correctedAfter: b };
+ }
+ }
+
+ return { kind: 'none' };
+}
+
+// The single value the ETL needs: the corrected value_after when the text confirms a double-count, else
+// null (leave value_after as the source gave it).
+export function restatedValueAfter(input: AmendmentValueInput): number | null {
+ const t = classifyAmendmentValue(input);
+ return t.kind === 'total_restated' || t.kind === 'unchanged_restated' ? t.correctedAfter : null;
+}
+
+export function isGenuineIncrement(input: AmendmentValueInput): boolean {
+ return classifyAmendmentValue(input).kind === 'genuine_increment';
+}
+
+// Convenience for the ETL staging: the treatment label to store on the raw amendment row (NULL when no
+// signal), and the corrected value_after (NULL unless a double-count was confirmed). A non-null treatment
+// tells derive/normalize NOT to arithmetic-flag the row (it is either corrected or confirmed-genuine).
+export function amendmentValueTreatment(input: AmendmentValueInput): {
+ treatment: 'total_restated' | 'unchanged_restated' | 'genuine_increment' | null;
+ restatedAfter: number | null;
+} {
+ const t = classifyAmendmentValue(input);
+ return {
+ treatment: t.kind === 'none' ? null : t.kind,
+ restatedAfter:
+ t.kind === 'total_restated' || t.kind === 'unchanged_restated' ? t.correctedAfter : null,
+ };
+}
diff --git a/packages/ingest/src/base.test.ts b/packages/ingest/src/base.test.ts
index 48f5f3e5..cb577d20 100644
--- a/packages/ingest/src/base.test.ts
+++ b/packages/ingest/src/base.test.ts
@@ -12,6 +12,7 @@ import {
toInt,
toPeriodDate,
toReal,
+ toSignedReal,
} from './base';
const FIXED_NOW = new Date('2026-06-11T12:00:00Z');
@@ -111,6 +112,92 @@ describe('base EOP mapper', () => {
expect(toPeriodDate('2025-06-01', FIXED_NOW)).toBe('2025-06-01');
expect(baseSqlLiteral('tenders', 'end_date', '2043-12-31')).toBe("'2043-12-31'");
});
+
+ // An annex can REDUCE a contract, and ЦАИС ЕОП publishes that as a negative
+ // contractValueDifference. Coercing it with toReal dropped the sign - and with it the whole row's
+ // delta - so every value-reducing annex silently lost its recorded change.
+ it('keeps a value-reducing annex delta negative instead of nulling it', () => {
+ const row = mapBaseRecord(
+ 'annexes',
+ {
+ uniqueProcurementNumber: '00224-2025-0005',
+ contractNumber: '212221',
+ publicationDate: '05.03.2026',
+ lastContractValue: '24837,96',
+ currentContractValue: '24492,80',
+ contractValueDifference: '-345,16',
+ },
+ { day: '2026-03-05', fetchedAt: '2026-03-05T00:00:00Z' },
+ );
+
+ expect(row?.value_before).toBe(24837.96);
+ expect(row?.value_after).toBe(24492.8);
+ expect(row?.value_delta).toBe(-345.16);
+ // The literal must stay a bare number, not a quoted string, or the staging INSERT changes type.
+ expect(baseSqlLiteral('annexes', 'value_delta', row?.value_delta)).toBe('-345.16');
+ });
+
+ // #305 Tier-2: base.ts runs the validated основание-text heuristic (amendment-total.ts) for annexes and
+ // persists value_treatment + value_after_restated onto the raw row. A doubled value_after whose text
+ // announces the NEW TOTAL ("…на ") is restated to that true total; an untreated annex stays NULL.
+ it('populates value_treatment/value_after_restated for an annex whose text announces a new total', () => {
+ const row = mapBaseRecord(
+ 'annexes',
+ {
+ uniqueProcurementNumber: '00224-2025-0009',
+ contractNumber: '990001',
+ publicationDate: '05.03.2026',
+ lastContractValue: '442000',
+ currentContractValue: '981240', // doubled: source put the new TOTAL in the change field
+ contractValueDifference: '539240',
+ contractCurrency: 'BGN',
+ changeReason: 'Общата стойност на договора се променя на 539 240 лв.',
+ },
+ { day: '2026-03-05', fetchedAt: '2026-03-05T00:00:00Z' },
+ );
+
+ expect(row?.value_after).toBe(981240); // raw after left as the source gave it
+ expect(row?.value_treatment).toBe('total_restated');
+ expect(row?.value_after_restated).toBe(539240); // the corrected true total
+ // The restated total must serialise as a bare number for the staging INSERT, not a quoted string.
+ expect(baseSqlLiteral('annexes', 'value_after_restated', row?.value_after_restated)).toBe(
+ '539240',
+ );
+ });
+
+ it('leaves value_treatment/value_after_restated NULL for a >2× annex with no text total (not exact-2×)', () => {
+ // 2.5× (not exact 2×) and no announced total in the text ⇒ no confident signal ⇒ left to the flag.
+ const row = mapBaseRecord(
+ 'annexes',
+ {
+ uniqueProcurementNumber: '00224-2025-0010',
+ contractNumber: '990002',
+ publicationDate: '05.03.2026',
+ lastContractValue: '1000000',
+ currentContractValue: '2500000',
+ contractValueDifference: '1500000',
+ contractCurrency: 'BGN',
+ },
+ { day: '2026-03-05', fetchedAt: '2026-03-05T00:00:00Z' },
+ );
+
+ expect(row?.value_treatment).toBeNull();
+ expect(row?.value_after_restated).toBeNull();
+ });
+
+ it('coerces signed reals without loosening the magnitude-only fields', () => {
+ expect(toSignedReal('-345,16')).toBe(-345.16);
+ expect(toSignedReal('-1 234,56')).toBe(-1234.56);
+ expect(toSignedReal('5833333,33')).toBe(5833333.33);
+ expect(toSignedReal('0')).toBe(0);
+ expect(toSignedReal(null)).toBeNull();
+ expect(toSignedReal('')).toBeNull();
+ expect(toSignedReal('--5')).toBeNull();
+ expect(toSignedReal('-abc')).toBeNull();
+ expect(toSignedReal(-(MAX_PLAUSIBLE_VALUE + 1))).toBeNull();
+ // Magnitude fields keep rejecting a minus sign: there a negative means corrupt input.
+ expect(toReal('-345,16')).toBeNull();
+ });
});
describe('offline SQL literal hardening', () => {
diff --git a/packages/ingest/src/base.ts b/packages/ingest/src/base.ts
index a2c43c01..4020ee3b 100644
--- a/packages/ingest/src/base.ts
+++ b/packages/ingest/src/base.ts
@@ -1,5 +1,7 @@
// Base EOP plain-JSON adapter helpers. Pure and Worker-safe: no Node APIs.
+import { amendmentValueTreatment } from './amendment-total.ts';
+
export type BaseCategory = 'contracts' | 'tenders' | 'annexes';
export type BaseCoercionKind =
| 'text'
@@ -7,6 +9,7 @@ export type BaseCoercionKind =
| 'real'
| 'bool'
| 'date'
+ | 'real_signed'
| 'secured_inverse'
| 'variants_enum';
export type BaseStagingValue = string | number | null;
@@ -94,6 +97,19 @@ export function toReal(v: unknown): number | null {
return Number.isFinite(n) && n >= 0 && n <= MAX_PLAUSIBLE_VALUE ? n : null;
}
+// contractValueDifference is the one published figure that is legitimately negative - an annex can
+// reduce a contract, and ЦАИС ЕОП publishes that as e.g. "-345,16". Every other REAL column here is a
+// magnitude (values, estimates, subcontracting), where a minus sign means corrupt input, so toReal
+// keeps rejecting negatives rather than being loosened for all of them.
+export function toSignedReal(v: unknown): number | null {
+ const s = clean(v);
+ if (s === null) return null;
+ const compact = s.replace(/\s/g, '');
+ if (!compact.startsWith('-')) return toReal(compact);
+ const magnitude = toReal(compact.slice(1));
+ return magnitude === null ? null : -magnitude;
+}
+
export function toBool(v: unknown): number | null {
const s = clean(v);
if (s === null) return null;
@@ -131,6 +147,7 @@ function toVariants(v: unknown): number | null {
export function coerce(kind: BaseCoercionKind, v: unknown): BaseStagingValue {
if (kind === 'int') return toInt(v);
if (kind === 'real') return toReal(v);
+ if (kind === 'real_signed') return toSignedReal(v);
if (kind === 'bool') return toBool(v);
if (kind === 'date') return toISODate(v);
if (kind === 'secured_inverse') return toSecuredFinancing(v);
@@ -297,7 +314,7 @@ export const BASE_CATEGORIES: Record = {
field('contract_date', 'contractDate', 'date'),
field('value_before', 'lastContractValue', 'real'),
field('value_after', 'currentContractValue', 'real'),
- field('value_delta', 'contractValueDifference', 'real'),
+ field('value_delta', 'contractValueDifference', 'real_signed'),
field('currency', 'contractCurrency', 'text'),
field('contract_subject', 'contractSubject', 'text'),
field('awarded_to_group', 'awardedToGroup', 'bool'),
@@ -311,6 +328,10 @@ export const BASE_CATEGORIES: Record = {
field('description', 'changeDescription', 'text'),
field('reason', 'changeReason', 'text'),
field('circumstances', 'changeReasonDescription', 'text'),
+ // #305 Tier-2 — computed from the основание text after the generic mapping (see mapBaseRecord), not
+ // read from a source key. key=null keeps the generic loop from touching them; mapBaseRecord sets them.
+ field('value_treatment', null, 'text'),
+ field('value_after_restated', null, 'real'),
field('outside_zop', 'isExceptionContract', 'bool'),
field('exemption_legal_basis', 'directAwardJustification', 'text'),
field('correction_number', null, 'text'),
@@ -364,9 +385,34 @@ export function mapBaseRecord(
if (!cfg.keep(record)) return null;
const row: BaseStagingRow = fixedValues(cat, meta);
for (const f of cfg.fields) row[f.column] = f.key === null ? null : coerce(f.kind, record[f.key]);
+ // #305 Tier-2 — EOP annexes only: classify value_delta from the основание free text (the validated
+ // heuristic in amendment-total.ts) and persist the treatment label + corrected total onto the raw row.
+ // OCDS annexes never reach here (ocds.ts stages them, and they carry value_after = null anyway).
+ if (cat === 'annexes') {
+ const treatment = amendmentValueTreatment({
+ valueBefore: numOrNull(row.value_before),
+ valueAfter: numOrNull(row.value_after),
+ valueDelta: numOrNull(row.value_delta),
+ currency: strOrNull(row.currency),
+ texts: [strOrNull(row.description), strOrNull(row.reason), strOrNull(row.circumstances)],
+ // #305 — outside-ЗОП exception contracts (isExceptionContract) are not bound by чл.116's +50% cap,
+ // so the text-free exact-2× restatement must not fire on them (see amendment-total.ts rule 3).
+ outsideZop: numOrNull(row.outside_zop) === 1,
+ });
+ row.value_treatment = treatment.treatment;
+ row.value_after_restated = treatment.restatedAfter;
+ }
return row;
}
+function numOrNull(v: BaseStagingValue | undefined): number | null {
+ return typeof v === 'number' ? v : null;
+}
+
+function strOrNull(v: BaseStagingValue | undefined): string | null {
+ return typeof v === 'string' ? v : null;
+}
+
// Hard ceiling on a single text literal's character length. EOP/registry text fields
// (subjects, descriptions, names) are well under this; anything larger is corrupt or
// hostile and is truncated rather than passed to sqlite (avoids SQLITE_TOOBIG / abuse of
@@ -432,7 +478,7 @@ export function baseSqlLiteral(
): string {
if (value === null || value === undefined) return 'NULL';
const kind = baseColumnKind(cat, column);
- if (['int', 'real', 'bool', 'secured_inverse', 'variants_enum'].includes(kind)) {
+ if (['int', 'real', 'real_signed', 'bool', 'secured_inverse', 'variants_enum'].includes(kind)) {
return String(value);
}
return escapeSqlText(String(value));
diff --git a/packages/ingest/src/fx.test.ts b/packages/ingest/src/fx.test.ts
index b6470b36..7f8fdc2c 100644
--- a/packages/ingest/src/fx.test.ts
+++ b/packages/ingest/src/fx.test.ts
@@ -209,6 +209,65 @@ describe('loadFxRates', () => {
await expect(loadFxRates(d1, { fetchedAt: FETCHED_AT, fetchFn })).rejects.toThrow(/HTTP 500/);
});
+ // Same defect class as the EOP reads: a body that is never read holds its stream open for the rest
+ // of the invocation. The 404 path is the most-travelled one here — Frankfurter answers 404 for any
+ // base currency it does not serve, and the loader deliberately continues past it.
+ it('releases the body of every response it walks away from', async () => {
+ const openBody = () => {
+ let cancelled = false;
+ const body = new ReadableStream({
+ start(c) {
+ c.enqueue(new TextEncoder().encode('x'));
+ },
+ cancel() {
+ cancelled = true;
+ },
+ });
+ return { body, cancelled: () => cancelled };
+ };
+
+ // 404 — unsupported currency, the loader warns and moves on.
+ {
+ const { db, d1 } = fxDb();
+ stageContract(db, 'USD', '2026-07-08');
+ const b = openBody();
+ const fetchFn = vi.fn(
+ async () => new Response(b.body, { status: 404 }),
+ ) as unknown as typeof fetch;
+ const summary = await loadFxRates(d1, { fetchedAt: FETCHED_AT, fetchFn });
+ expect(summary.warnings.join(' ')).toMatch(/not served by frankfurter/);
+ expect(b.cancelled()).toBe(true);
+ }
+
+ // Non-OK — the loader throws.
+ {
+ const { db, d1 } = fxDb();
+ stageContract(db, 'USD', '2026-07-08');
+ const b = openBody();
+ const fetchFn = vi.fn(
+ async () => new Response(b.body, { status: 500 }),
+ ) as unknown as typeof fetch;
+ await expect(loadFxRates(d1, { fetchedAt: FETCHED_AT, fetchFn })).rejects.toThrow(/HTTP 500/);
+ expect(b.cancelled()).toBe(true);
+ }
+
+ // Redirected to another host — the host pin throws.
+ {
+ const { db, d1 } = fxDb();
+ stageContract(db, 'USD', '2026-07-08');
+ const b = openBody();
+ const fetchFn = vi.fn(async () => {
+ const res = new Response(b.body, { status: 200 });
+ Object.defineProperty(res, 'url', {
+ value: 'https://evil.example/v1/2026-07-01..2026-07-08',
+ });
+ return res;
+ }) as unknown as typeof fetch;
+ await expect(loadFxRates(d1, { fetchedAt: FETCHED_AT, fetchFn })).rejects.toThrow();
+ expect(b.cancelled()).toBe(true);
+ }
+ });
+
it('keeps successfully loaded currencies when another currency fails', async () => {
const { db, d1 } = fxDb();
stageContract(db, 'USD', '2026-07-08');
diff --git a/packages/ingest/src/fx.ts b/packages/ingest/src/fx.ts
index 7d78a4ba..f24f5323 100644
--- a/packages/ingest/src/fx.ts
+++ b/packages/ingest/src/fx.ts
@@ -4,6 +4,20 @@
// validation) live here once so the two implementations cannot drift; the Worker-native loader and
// its coverage guard are D1-based and pure-fetch, safe for workerd (no Node APIs).
+// A response body that is never read keeps its stream - and the connection behind it - open for the
+// rest of the invocation; the collector is not a substitute. Deliberately NOT awaited: cancelling only
+// needs to be INITIATED for the runtime to release the stream, and awaiting it would make the caller
+// hostage to a cancel() that never settles. Kept private here rather than shared with apps/etl: this
+// file is also loaded as raw TypeScript by the Node CLI (scripts/load-fx.mjs), where an extensionless
+// relative import does not resolve and a `.ts` one needs allowImportingTsExtensions repo-wide.
+function discardBody(res: Response): void {
+ try {
+ void res.body?.cancel().catch(() => {});
+ } catch {
+ // Already consumed, locked, or errored - there is nothing left to release either way.
+ }
+}
+
// Canonical host. The legacy api.frankfurter.app now 301-redirects here — with host-pinned
// fetches (assertSameFinalHost) the legacy host would fail closed, so point at the target
// directly. Same response shape; the /v1 prefix is required on the .dev host.
@@ -220,15 +234,25 @@ export async function loadFxRates(db: D1Database, opts: LoadFxOptions): Promise<
try {
const url = fxSeriesUrl(gap.currency, start, end, api);
const res = await fetchFn(url);
- assertSameFinalHost(url, res.url);
+ try {
+ assertSameFinalHost(url, res.url);
+ } catch (err) {
+ discardBody(res);
+ throw err;
+ }
if (res.status === 404) {
// Frankfurter answers 404 for a base currency it does not serve — permanent, not
// transient: warn and move on (CLI parity), never brick the cron on one odd currency.
+ // The body still has to be released — this `continue` is the most-travelled path here.
+ discardBody(res);
load.status = 'unsupported';
summary.warnings.push(`currency ${gap.currency} not served by frankfurter`);
continue;
}
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ if (!res.ok) {
+ discardBody(res);
+ throw new Error(`HTTP ${res.status}`);
+ }
const { rows, warnings } = parseFxSeries(await res.json(), gap.currency, `${start}..${end}`);
summary.warnings.push(...warnings);
await upsertFxRates(db, rows, opts.fetchedAt);
diff --git a/packages/ingest/src/ocds.test.ts b/packages/ingest/src/ocds.test.ts
index 014f65f9..fd8b15cd 100644
--- a/packages/ingest/src/ocds.test.ts
+++ b/packages/ingest/src/ocds.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
classifyBucketKey,
computeCatchupWindow,
+ fullDeriveIsSafe,
releaseToAmendments,
releaseToContracts,
releaseToLots,
@@ -288,6 +289,48 @@ describe('releaseToAmendments', () => {
expect(rows).toHaveLength(1);
expect(rows[0]?.contract_number).toBe('DOC-1');
});
+
+ // Issue #286: the УНП is absent from OCDS releases, so the amendment must carry tender.id (the EOP
+ // procedure id) for the ETL bridge to recover the УНП. And the release value is the pre-amendment
+ // value, so it is stored as value_before with a null value_after — an OCDS row must never masquerade
+ // as a fresh current_value. `unp` still holds the ocid here; rewriting it to the real УНП is the job
+ // of derive-amendments.sql / refresh-slice.sql, not the pure flattener's.
+ it('captures the tender.id bridge and records the release value as value_before (issue #286)', () => {
+ const rows = releaseToAmendments(
+ {
+ ...release,
+ tag: ['contractAmendment'],
+ tender: { ...release.tender, id: '425867' },
+ contracts: [
+ {
+ ...release.contracts![0]!,
+ id: '90029',
+ value: { amount: 21_602_081.98, currency: 'EUR' },
+ amendments: [{ description: 'Анекс 1', rationale: 'ЗОП чл. 116' }],
+ },
+ ],
+ },
+ meta,
+ );
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ contract_number: '90029',
+ unp: 'ocds-bg-2026-000123',
+ tender_ext_id: '425867',
+ value_before: 21_602_081.98,
+ value_after: null,
+ value_delta: null,
+ currency: 'EUR',
+ description: 'Анекс 1',
+ });
+ });
+
+ it('captures a null tender_ext_id when the release has no tender.id', () => {
+ const rows = releaseToAmendments({ ...release, tag: ['contractUpdate'] }, meta);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]?.tender_ext_id).toBeNull();
+ });
});
describe('OCDS enrichment mappers', () => {
@@ -352,6 +395,27 @@ describe('bucket key and catchup helpers', () => {
computeCatchupWindow({ maxLoadedDate: '2026-06-01', today: '2026-06-07', lookbackDays: 3 }),
).toEqual({ from: '2026-05-29', to: '2026-06-07' });
});
+
+ it('allows a full derive only when the window reaches the start of the feed', () => {
+ // Initial backfill: nothing to lose, any window is safe.
+ expect(
+ fullDeriveIsSafe({ windowFrom: '2026-06-10', feedStart: '2020-01-01', hasCorpus: false }),
+ ).toBe(true);
+ // Whole feed reloaded into staging — the rebuild is complete.
+ expect(
+ fullDeriveIsSafe({ windowFrom: '2020-01-01', feedStart: '2020-01-01', hasCorpus: true }),
+ ).toBe(true);
+ // A catch-up window over an existing corpus would drop everything before it.
+ expect(
+ fullDeriveIsSafe({ windowFrom: '2026-06-10', feedStart: '2020-01-01', hasCorpus: true }),
+ ).toBe(false);
+ });
+
+ it('rejects malformed days rather than silently allowing a full derive', () => {
+ expect(() =>
+ fullDeriveIsSafe({ windowFrom: '10-06-2026', feedStart: '2020-01-01', hasCorpus: true }),
+ ).toThrow(/windowFrom/);
+ });
});
describe('splitSqlStatements', () => {
diff --git a/packages/ingest/src/ocds.ts b/packages/ingest/src/ocds.ts
index fb460cfa..131ba304 100644
--- a/packages/ingest/src/ocds.ts
+++ b/packages/ingest/src/ocds.ts
@@ -216,6 +216,11 @@ export interface AmendmentStagingRow {
reason: string | null;
circumstances: string | null;
sme: string | null;
+ // The EOP procedure id (OCDS `tender.id`) — the bridge to the УНП. The OCID is a surrogate; the
+ // real УНП is not in the OCDS release, so the ETL recovers it via tender_ext_id → raw_tenders → unp
+ // in scripts/derive-amendments.sql (and scripts/refresh-slice.sql on the incremental path), mirroring
+ // the OCDS-lots bridge that lives in scripts/normalize-raw.sql. See issue #286.
+ tender_ext_id: string | null;
}
export interface PartyStagingRow {
@@ -317,7 +322,7 @@ export function releaseToContracts(rel: OcdsRelease, meta: OcdsMeta): ContractSt
dataset_variant: 'OCDS',
seq_no: null,
document_number: rel.id ?? null,
- contract_number: c.id ?? null,
+ contract_number: clean(c.id),
contract_date: dateOnly(c.dateSigned),
published_at: ctx.published_at,
unp: rel.ocid ?? null,
@@ -363,7 +368,7 @@ export function releaseToAmendments(rel: OcdsRelease, meta: OcdsMeta): Amendment
dataset_variant: 'OCDS',
seq_no: null,
document_number: rel.id ?? null,
- contract_number: c.id ?? null,
+ contract_number: clean(c.id),
contract_date: dateOnly(c.dateSigned),
published_at: ctx.published_at,
unp: rel.ocid ?? null,
@@ -375,14 +380,23 @@ export function releaseToAmendments(rel: OcdsRelease, meta: OcdsMeta): Amendment
contract_subject: c.title || sup.awardTitle || null,
contractor_eik: sup.eik,
contractor_name: sup.name,
- value_before: null,
- value_after: finiteNum(c.value?.amount),
+ // An OCDS contractAmendment/contractUpdate release carries the contract value as it stands in
+ // that release — measured against the EOP annex stream this is the value BEFORE the amendment,
+ // never a reliable "after" (issue #286). Record it as value_before with a null value_after so an
+ // OCDS row can never drive the derived current_value (derive-amendments.sql selects the latest
+ // non-null value_after); the authoritative after-value comes from the EOP annex.
+ value_before: finiteNum(c.value?.amount),
+ value_after: null,
value_delta: null,
currency: isoCurrency(c.value?.currency),
description: amd?.description || null,
reason: amd?.rationale || null,
circumstances: null,
sme: null,
+ // OCDS tender.id === the EOP procedure id; the bridge to the УНП (derive-amendments.sql, and
+ // refresh-slice.sql on the incremental path). The ocid stored in `unp` here is a surrogate that
+ // the ETL bridge rewrites to the real УНП.
+ tender_ext_id: clean(rel.tender?.id),
},
];
});
@@ -452,6 +466,27 @@ export function computeCatchupWindow({
return { from: from > today ? today : from, to: today };
}
+/**
+ * A full derive rebuilds the domain from whatever the staging tables hold — `scripts/normalize-raw.sql`
+ * opens with `DELETE FROM contracts` — so every contract outside the loaded window is dropped. It is
+ * only sound when the window reaches back to the first day the feed is loaded from, or when there is
+ * no corpus yet (the initial backfill). A gap-aware catch-up window never does, which is why
+ * `--catchup` derives a slice.
+ */
+export function fullDeriveIsSafe({
+ windowFrom,
+ feedStart,
+ hasCorpus,
+}: {
+ windowFrom: string;
+ feedStart: string;
+ hasCorpus: boolean;
+}): boolean {
+ validateDay(windowFrom, 'windowFrom');
+ validateDay(feedStart, 'feedStart');
+ return !hasCorpus || windowFrom <= feedStart;
+}
+
export function daysInWindow(from: string, to: string): number {
validateDay(from, 'from');
validateDay(to, 'to');
@@ -523,6 +558,7 @@ export const AMENDMENT_STAGING_COLS: (keyof AmendmentStagingRow)[] = [
'reason',
'circumstances',
'sme',
+ 'tender_ext_id',
];
export const PARTY_STAGING_COLS: (keyof PartyStagingRow)[] = [
diff --git a/packages/ingest/src/refresh-full-clear.test.ts b/packages/ingest/src/refresh-full-clear.test.ts
new file mode 100644
index 00000000..c90f9f4a
--- /dev/null
+++ b/packages/ingest/src/refresh-full-clear.test.ts
@@ -0,0 +1,77 @@
+///
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { fullClearTables } from './refresh';
+
+// fullClearTables answers one question for scripts/import.mjs: which tables does a full derive empty,
+// and therefore what does a partial window destroy? The guard that used to ask it named `contracts`
+// alone while the clear had reached fourteen tables — so the tests that matter here are the ones that
+// keep the answer tied to the SQL rather than to a copy of it.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const normalizeRaw = readFileSync(resolve(root, 'scripts/normalize-raw.sql'), 'utf8');
+
+describe('fullClearTables', () => {
+ it('reads the block out of the real normalize-raw.sql', () => {
+ const tables = fullClearTables(normalizeRaw);
+ // The base domain tables: whatever else the block grows, losing any of these is losing the corpus.
+ expect(tables).toEqual(
+ expect.arrayContaining(['contracts', 'lots', 'tenders', 'bidders', 'authorities']),
+ );
+ });
+
+ it('stops before the per-run metadata resets further down the file', () => {
+ // normalize-raw.sql also clears data_freshness and pipeline_stats, which are rewritten every run.
+ // Counting them as corpus would make the guard refuse EVERY full derive, initial backfill included
+ // — a guard that always refuses gets deleted, so this boundary is load-bearing.
+ const tables = fullClearTables(normalizeRaw);
+ expect(tables).not.toContain('data_freshness');
+ expect(tables).not.toContain('pipeline_stats');
+ });
+
+ it('keeps the marker and the block adjacent', () => {
+ // If the marker is dropped or drifts away from the DELETEs, this returns [] and import.mjs throws
+ // rather than silently deciding the corpus is empty and letting the destructive path through.
+ expect(fullClearTables(normalizeRaw).length).toBeGreaterThanOrEqual(5);
+ });
+
+ it('takes only the marked block, and only unqualified deletes', () => {
+ const sql = [
+ 'DELETE FROM before_the_marker;',
+ '-- @full-clear',
+ 'DROP TABLE IF EXISTS scratch;',
+ 'DELETE FROM contracts;',
+ "DELETE FROM lots WHERE id = 'x';", // scoped: not a full clear
+ 'DELETE FROM authorities;',
+ '',
+ 'DELETE FROM after_the_block;',
+ ].join('\n');
+ expect(fullClearTables(sql)).toEqual(['contracts', 'authorities']);
+ });
+
+ it('sees a table however it is quoted', () => {
+ // `DELETE FROM "search_index";` is valid SQLite and reads as pure formatting. A bare-identifier
+ // matcher dropped it from the list, which silently reopened the data-loss hole: the guard would
+ // then wave through a corpus whose only populated table was the re-quoted one.
+ const sql = [
+ '-- @full-clear',
+ 'DELETE FROM "search_index";',
+ 'DELETE FROM `flow_pairs`;',
+ 'DELETE FROM [home_totals];',
+ 'delete from contracts;',
+ '',
+ ].join('\n');
+ expect(fullClearTables(sql)).toEqual([
+ 'search_index',
+ 'flow_pairs',
+ 'home_totals',
+ 'contracts',
+ ]);
+ });
+
+ it('returns nothing when the marker is absent', () => {
+ expect(fullClearTables('DELETE FROM contracts;\nDELETE FROM lots;\n')).toEqual([]);
+ });
+});
diff --git a/packages/ingest/src/refresh-officials.test.ts b/packages/ingest/src/refresh-officials.test.ts
index 55170ef2..2c0ebb8f 100644
--- a/packages/ingest/src/refresh-officials.test.ts
+++ b/packages/ingest/src/refresh-officials.test.ts
@@ -20,6 +20,9 @@ const migrations = [
'0000_init.sql',
'0001_flow_pairs_bidder_index.sql',
'0003_related_persons_foundation.sql',
+ // The officials batch now joins the Trade Register evidence seal (#279, ADR-0033) — without 0006 the
+ // real refresh-slice.sql this test executes cannot parse.
+ '0009_interest_link_evidence.sql',
].map((f) => resolve(root, 'packages/db/migrations', f));
const refreshSlicePath = resolve(root, 'scripts/refresh-slice.sql');
@@ -67,6 +70,11 @@ INSERT INTO interest_links
-- her — dropping the s.status='published' guard would collapse it behind an invisible self stake (false neg).
('il:ys','person:ЯНА|444','person:ЯНА','eik:444','444','ДЕЛТА ООД','exact_name_key','v1','C_hold','owns','private_ownership',0,'none',1,'2020','2023',1,20000,'2021','2021','held'),
('il:yf','person:ЯНА|444|family','person:ЯНА','eik:444','444','ДЕЛТА ООД','exact_name_key','v1','B_distinctive','related','family_ownership',1,'none',1,'2020','2023',1,20000,'2021','2021','published');
+-- Every link carries an evidence seal (#279, ADR-0033): the officials batch requires a publishing rung,
+-- so a fixture without seals indexes NOBODY and every assertion below passes vacuously. Derived from
+-- interest_links, so a row added later is sealed automatically.
+INSERT INTO interest_link_evidence (link_key, evidence_kind, registry_role, matched_fact, lookup_date, rules_version, live_status)
+ SELECT link_key, 'document', 'owner', 'role:owner:CR_F_19_L', '2026-08-05', 'tr-rules-1', 'live' FROM interest_links;
`;
describe('ETL refresh-slice officials batch (the live parse-then-execute path)', () => {
diff --git a/packages/ingest/src/refresh.ts b/packages/ingest/src/refresh.ts
index 4159da90..0aed14ad 100644
--- a/packages/ingest/src/refresh.ts
+++ b/packages/ingest/src/refresh.ts
@@ -98,6 +98,12 @@ const LEGACY_TRANSIENT_STAGING_TABLES = [
'raw_egov_amendments',
] as const;
+// Scratch tables that live only for the span of a single derive step. Each is DROP-guarded at the top of
+// its own script, so it self-heals on the next run; listing it here also sweeps it after an aborted run so
+// it never lingers in D1 (review nikimilenkov LOW 2 — #306's value-resolver scratch table). Not part of
+// work-staging-schema.sql, so it stays out of transientStagingStatements' recreate path.
+const SCRATCH_TABLES = ['amendment_contract_resolve'] as const;
+
function touchesTransientStaging(statement: string): boolean {
return TRANSIENT_STAGING_TABLES.some((table) => statement.includes(table));
}
@@ -108,8 +114,47 @@ export function transientStagingStatements(workStagingSchemaSql: string): string
);
}
+const FULL_CLEAR_MARKER = /^--\s*@full-clear\b/i;
+// All three SQLite quoting styles, not just the bare identifier. Rewriting one line as
+// `DELETE FROM "search_index";` is a valid, invisible formatting change — and with a bare-only
+// matcher it would drop that table out of the guard's list and quietly reopen the hole this parser
+// exists to close.
+const DELETE_FROM =
+ /^DELETE\s+FROM\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_]*))\s*;?\s*$/i;
+
+/**
+ * The tables `scripts/normalize-raw.sql` empties before rebuilding the domain from staging, read out
+ * of the SQL rather than restated in JS. The guard that consumes this list used to ask about
+ * `contracts` alone while the clear had grown to fourteen tables — a hardcoded copy of a destructive
+ * list is a data-loss bug on a timer, so the list has exactly one home.
+ *
+ * Scoped to the `@full-clear` block on purpose: the same file later resets `data_freshness` and
+ * `pipeline_stats`, which are per-run metadata. Counting those as corpus would make the guard refuse
+ * every full derive, including the initial backfill it is supposed to let through.
+ */
+export function fullClearTables(normalizeRawSql: string): string[] {
+ const tables: string[] = [];
+ let inBlock = false;
+ for (const line of normalizeRawSql.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (FULL_CLEAR_MARKER.test(trimmed)) {
+ inBlock = true;
+ continue;
+ }
+ if (!inBlock) continue;
+ const hit = trimmed.match(DELETE_FROM);
+ if (hit) {
+ tables.push((hit[1] ?? hit[2] ?? hit[3] ?? hit[4])!);
+ continue;
+ }
+ // Comments and the DROP TABLEs share the block; a blank line ends it.
+ if (trimmed === '') break;
+ }
+ return tables;
+}
+
export function dropTransientStagingStatements(): string[] {
- return [...TRANSIENT_STAGING_TABLES, ...LEGACY_TRANSIENT_STAGING_TABLES]
+ return [...SCRATCH_TABLES, ...TRANSIENT_STAGING_TABLES, ...LEGACY_TRANSIENT_STAGING_TABLES]
.reverse()
.map((table) => `DROP TABLE IF EXISTS ${table}`);
}
diff --git a/packages/ingest/tsconfig.json b/packages/ingest/tsconfig.json
index b8ac7d61..9d4ac835 100644
--- a/packages/ingest/tsconfig.json
+++ b/packages/ingest/tsconfig.json
@@ -1,6 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
+ // base.ts imports './amendment-total.ts' with an explicit .ts extension (the ETL runtime consumes this
+ // source under plain Node). allowImportingTsExtensions is set in tsconfig.base.json for every consumer.
"types": ["@cloudflare/workers-types"]
},
"include": ["src"]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 304ec682..a8037bfb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,7 @@ overrides:
sharp: ^0.35.3
postcss: ^8.5.18
valibot: ^1.4.2
+ nanoid: ^3.3.17
importers:
@@ -1643,8 +1644,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.16:
- resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ nanoid@3.3.18:
+ resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -3369,7 +3370,7 @@ snapshots:
ms@2.1.3: {}
- nanoid@3.3.16: {}
+ nanoid@3.3.18: {}
node-releases@2.0.45: {}
@@ -3406,7 +3407,7 @@ snapshots:
postcss@8.5.24:
dependencies:
- nanoid: 3.3.16
+ nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 200559f1..87ca4b8e 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -38,6 +38,10 @@ overrides:
# valibot <1.4.2 — GHSA-5qjj-4xww-7phc (MEDIUM); transitive, build-time only, never
# ships to the Worker.
valibot: '^1.4.2'
+ # nanoid <3.3.17 — GHSA-2v37-7h3g-55p8 (HIGH, 8.2); pulled in by postcss (itself pinned
+ # above), so postcss 8.5.24 keeps resolving 3.3.16 on its own. Build-time
+ # only, via vite/vitest — never ships to the Worker.
+ nanoid: '^3.3.17'
onlyBuiltDependencies:
- esbuild
diff --git a/scripts/cacbg/audit.mjs b/scripts/cacbg/audit.mjs
index 50ff2933..6ca5f3a7 100644
--- a/scripts/cacbg/audit.mjs
+++ b/scripts/cacbg/audit.mjs
@@ -7,11 +7,14 @@
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { nameDistinctiveness } from './classify.mjs';
+import fs from 'node:fs';
import { companyCandidates, declaredEiks } from './extract-companies.mjs';
+import { RULES_VERSION, isSealedFact } from '../tr/evidence.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const DB = process.env.CACBG_DB || path.join(ROOT, 'data/work/backfill.sqlite');
+const STAGING = process.env.CACBG_STAGING || path.join(ROOT, 'scratch/cacbg/staging');
+const SNAPSHOT = path.join(STAGING, 'published-snapshot.json');
const { companyNameKey } = await import('../../packages/shared/src/company-name-key.ts');
const db = new DatabaseSync(DB, { readOnly: true });
@@ -31,12 +34,34 @@ const published = db
`
SELECT il.id, il.link_key, il.person_id, il.eik, il.entity_key, il.match_method, il.publish_tier,
il.bidder_id, il.relation, il.contemporaneous, il.contract_value_eur,
- b.name AS bidder_name, b.eik_normalized AS bidder_eik, b.eik_valid AS bidder_eik_valid
+ b.name AS bidder_name, b.eik_normalized AS bidder_eik, b.eik_valid AS bidder_eik_valid,
+ -- LEFT JOIN, deliberately: a published link with NO seal is the finding, so it must reach the
+ -- loop rather than be filtered out of it.
+ e.evidence_kind, e.registry_role, e.matched_fact
FROM interest_links il JOIN bidders b ON b.id = il.bidder_id
+ LEFT JOIN interest_link_evidence e ON e.link_key = il.link_key
WHERE il.status = 'published'`,
)
.all();
+// Every link's CURRENT status, for the monotonicity gate below. A link that left the published set
+// because it was SUPPRESSED is still in this table — the takedown is a status flip, not a delete — and
+// distinguishing that from a link that simply stopped being built is the difference between a declared
+// removal and a silent recall regression. Read here, while the handle is open.
+const statusNow = new Map(
+ db
+ .prepare('SELECT link_key, status FROM interest_links')
+ .all()
+ .map((r) => [r.link_key, r.status]),
+);
+
+// The only two evidence rungs that publish (ADR-0033 decision 1). Everything else withholds.
+const PUBLISHING_EVIDENCE = new Set(['document', 'confirmed']);
+// The closed vocabulary for a sealed matched_fact lives in evidence.mjs, next to the code that WRITES
+// it — one definition, so the gate can never permit a shape the writer has stopped emitting (or, worse,
+// the other way round). `isSealedFact` bounds the seat leg to a settlement's one or two tokens, which is
+// what stops a three-part Bulgarian name riding through behind the legitimate `seat:` prefix.
+
const findings = [];
const flag = (link, axis, detail) =>
findings.push({ axis, link_key: link.link_key, eik: link.eik, detail });
@@ -50,12 +75,16 @@ for (const l of published) {
// l.eik is a valid winner BEARING this name key (a stray/mis-attached ЕИК is still caught). The ЕИК+name
// double-lock itself is re-proven in the provenance pass below (A_eik_no_provenance).
if (!rec) flag(l, 'A_key_missing', `entity_key ${l.entity_key} not found in live bidder set`);
- else if (l.publish_tier === 'A_eik') {
+ else if (l.match_method === 'declared_eik') {
+ // Identity here is the declarant-provided ЕИК, not the name (ADR-0028), so a name shared by more
+ // than one winner is legitimate — the ЕИК picks exactly one. Require instead that l.eik is a valid
+ // winner BEARING this name key, which still catches a stray or mis-attached ЕИК. The ЕИК+name
+ // double-lock is re-proven independently in the provenance pass below.
if (!rec.valid.has(l.eik))
flag(
l,
'A_eik_not_winner',
- `A_eik published ${l.eik}, not among key ${l.entity_key}'s valid winners {${[...rec.valid].join(',')}}`,
+ `declared_eik published ${l.eik}, not among key ${l.entity_key}'s valid winners {${[...rec.valid].join(',')}}`,
);
} else if (rec.valid.size !== 1)
flag(
@@ -72,12 +101,34 @@ for (const l of published) {
if (!l.bidder_eik_valid)
flag(l, 'B_eik_invalid', `published on eik_valid=0 bidder ${l.bidder_name}`);
- // C. Tier honesty: B_distinctive must actually be distinctive by the same classifier that gated it.
- if (l.publish_tier === 'B_distinctive' && nameDistinctiveness(l.entity_key) !== 'distinctive')
+ // C. Evidence honesty (#279, ADR-0033). The tier IS the evidence kind now, so the axis that used to
+ // re-derive name distinctiveness re-derives the publishing rule instead: a published link must
+ // carry a seal, and that seal must be one of the two rungs that publish. `nameDistinctiveness`
+ // no longer gates publication at all — it survives only as a withholding filter inside the
+ // loader — so re-checking it here would assert a rule that is no longer in force.
+ if (!l.evidence_kind)
+ flag(l, 'C_no_evidence', `published with no Trade Register evidence seal (${l.bidder_name})`);
+ else if (!PUBLISHING_EVIDENCE.has(l.evidence_kind))
+ flag(
+ l,
+ 'C_withholding_evidence',
+ `published on evidence_kind='${l.evidence_kind}', which withholds (${l.bidder_name})`,
+ );
+ else if (l.evidence_kind !== l.publish_tier)
+ flag(
+ l,
+ 'C_tier_evidence_mismatch',
+ `publish_tier='${l.publish_tier}' but sealed evidence_kind='${l.evidence_kind}'`,
+ );
+
+ // C2. The PII rail, audited rather than assumed: matched_fact is a CLOSED vocabulary and can never
+ // carry a name. The registry deed's names are read only to produce a boolean and must never reach
+ // a served column (#279 §9, ADR-0033 decision 5). A schema cannot enforce this; this does.
+ if (!isSealedFact(l.matched_fact))
flag(
l,
- 'C_not_distinctive',
- `tier B_distinctive but nameDistinctiveness=${nameDistinctiveness(l.entity_key)} (${l.bidder_name})`,
+ 'C_matched_fact_shape',
+ `matched_fact='${l.matched_fact}' is outside the closed vocabulary — a name may have leaked`,
);
}
@@ -126,6 +177,63 @@ for (const l of nonExact) {
db.close();
+// D. Monotonicity — ADR-0033 decision 6, the correction of #279 §8.
+//
+// §8 asked for a seal kept „forever" and strictly-additive recomputation. That is unachievable: labels
+// flip, the deed cache expires, and a court can annul an entry (чл. 29 ЗТРРЮЛНЦ) with no rules change
+// at all. So the seal is NOT a store — it is re-derived every run — and monotonicity is enforced HERE,
+// as a gate, against the export load.mjs writes immediately before it drops the CACBG tables.
+//
+// The rule: a link that was published last run and is not published now is a REGRESSION unless the
+// rules themselves changed. Under an unchanged rules_version nothing licensed the removal, so it is a
+// hard finding. Under a changed one it is an intentional event and degrades to a printed diff.
+//
+// ship-related-persons.mjs's count floor cannot do this job: it compares a COUNT, so a one-for-one
+// swap — one true link silently dropped, one gained — leaves it perfectly quiet.
+const publishedNow = new Set(published.map((l) => l.link_key));
+let priorPublished = null;
+try {
+ priorPublished = JSON.parse(fs.readFileSync(SNAPSHOT, 'utf8'));
+} catch (e) {
+ // ENOENT is the legitimate first run — there is no prior surface to regress from. Anything else
+ // (unreadable, malformed) must not be swallowed into a silent pass of the gate.
+ if (e.code !== 'ENOENT') throw e;
+}
+const vanished = (priorPublished ?? []).filter((p) => !publishedNow.has(p.link_key));
+
+// The three grounds decision 6 sanctions for a removal. Anything else that vanished is a regression.
+//
+// Getting this set right is what stops the gate from deadlocking the mechanisms it points at. The
+// rules bump is the obvious one. The other two are not optional extras — each is the ONLY expressible
+// form of a removal the ADR names in prose:
+//
+// suppressed — the ADR-0031 takedown path, which decision 6 wires the court-annulled entry
+// (чл. 29 ЗТРРЮЛНЦ) to by name. It flips status published → suppressed with rules_version
+// untouched, so without this branch the one removal the ADR explicitly licenses hard-fails.
+// corrected — "a correction of wrong input". Suppression cannot express it: correcting the input
+// unbuilds the link, and load.mjs's B3 unused-suppression gate then fails the build for a
+// fingerprint that matched nothing. So the two sanctioned removals would fail in OPPOSITE
+// directions, leaving a real correction with no path at all. The flag is set by load.mjs from the
+// version-controlled, fingerprinted corrections list — a human decision recorded in git, not a
+// condition the audit can infer.
+//
+// Both are declared, both are reviewed, and both are still PRINTED below: a withdrawn public claim is
+// never silent, it just is not a build failure.
+const declaredRemoval = (p) =>
+ p.rules_version !== RULES_VERSION ||
+ p.corrected === true ||
+ statusNow.get(p.link_key) === 'suppressed';
+const regressions = vanished.filter((p) => !declaredRemoval(p));
+for (const p of regressions)
+ findings.push({
+ axis: 'D_monotonicity',
+ link_key: p.link_key,
+ eik: p.link_key.split('|')[1] ?? '',
+ // The link_key is named explicitly: it is the only handle a human has to go and look at which
+ // claim disappeared, and the shared axis report prints the ЕИК alone.
+ detail: `${p.link_key} published last run under rules_version ${p.rules_version} (unchanged) and is not published now — nothing licensed this removal`,
+ });
+
// Report
const byAxis = {};
for (const f of findings) (byAxis[f.axis] ??= []).push(f);
@@ -137,6 +245,29 @@ for (const [axis, fs] of Object.entries(byAxis)) {
for (const f of fs.slice(0, 20)) console.log(` - [${f.eik}] ${f.detail}`);
console.log('');
}
+if (priorPublished === null) {
+ console.log(`## monotonicity — no prior export at ${SNAPSHOT}; treating this as a first run\n`);
+} else {
+ const declared = vanished.filter(declaredRemoval);
+ console.log(
+ `## monotonicity — ${priorPublished.length} published last run, ${publishedNow.size} now; ` +
+ `${regressions.length} regression(s), ${declared.length} declared removal(s)\n`,
+ );
+ // Declared removals are not findings, but they are never silent: withdrawing a named public claim is
+ // exactly when a human should read WHICH claims went and on what ground. The ground is printed too —
+ // "removed" alone would flatten a court annulment, a corrected parse and a rules bump into one line.
+ for (const p of declared) {
+ const ground =
+ p.rules_version !== RULES_VERSION
+ ? `a rules change (${p.rules_version} → ${RULES_VERSION})`
+ : statusNow.get(p.link_key) === 'suppressed'
+ ? 'a suppression (ADR-0031 takedown path)'
+ : 'an acknowledged input correction';
+ console.log(` - ${p.link_key} removed under ${ground}`);
+ }
+ if (declared.length) console.log('');
+}
+
console.log(`## non-exact provenance (${provenance.length}) — verify each cross-check by eye`);
for (const p of provenance) {
console.log(` - ${p.method} [${p.eik}] winner="${p.winner}"`);
diff --git a/scripts/cacbg/audit.test.mjs b/scripts/cacbg/audit.test.mjs
index df36baa8..e0f8af4b 100644
--- a/scripts/cacbg/audit.test.mjs
+++ b/scripts/cacbg/audit.test.mjs
@@ -26,10 +26,15 @@ const dirs = [];
// Build a fixture DB (bidders + declarations + declared_interests + interest_links), run audit.mjs against
// it as a subprocess, and return { threw, out } — threw=true iff the audit exited non-zero (a hard finding).
-function buildAndAudit({ bidders, decls = [], dis = [], links }) {
+function buildAndAudit({ bidders, decls = [], dis = [], links, seals = [], snapshot = null }) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cacbg-audit-'));
dirs.push(dir);
const DB = path.join(dir, 'fixture.sqlite');
+ const staging = path.join(dir, 'staging');
+ fs.mkdirSync(staging, { recursive: true });
+ // The pre-wipe export load.mjs writes before it drops the CACBG tables. Absent === a first run.
+ if (snapshot)
+ fs.writeFileSync(path.join(staging, 'published-snapshot.json'), JSON.stringify(snapshot));
const db = new DatabaseSync(DB);
db.exec(`
CREATE TABLE bidders(id TEXT PRIMARY KEY, name TEXT, eik_normalized TEXT, eik_valid INT);
@@ -38,10 +43,16 @@ function buildAndAudit({ bidders, decls = [], dis = [], links }) {
CREATE TABLE interest_links(
id TEXT PRIMARY KEY, link_key TEXT, person_id TEXT, eik TEXT, entity_key TEXT, match_method TEXT,
publish_tier TEXT, bidder_id TEXT, relation TEXT, contemporaneous INT, contract_value_eur REAL, status TEXT);
+ -- The evidence seal (#279, migration 0006). The audit LEFT JOINs it, so it must exist even when a
+ -- case deliberately leaves a link unsealed — an unsealed published link is itself a finding.
+ CREATE TABLE interest_link_evidence(
+ link_key TEXT PRIMARY KEY, evidence_kind TEXT, registry_role TEXT, matched_fact TEXT,
+ entry_number TEXT, entry_date TEXT, lookup_date TEXT, rules_version TEXT, live_status TEXT);
${bidders.map((b) => `INSERT INTO bidders VALUES (${b});`).join('\n')}
${decls.map((d) => `INSERT INTO declarations VALUES (${d});`).join('\n')}
${dis.map((d) => `INSERT INTO declared_interests(declaration_id, entity_raw) VALUES (${d});`).join('\n')}
${links.map((l) => `INSERT INTO interest_links VALUES (${l});`).join('\n')}
+ ${seals.map((e) => `INSERT INTO interest_link_evidence(link_key,evidence_kind,registry_role,matched_fact,lookup_date,rules_version,live_status) VALUES (${e});`).join('\n')}
`);
db.close();
@@ -51,7 +62,12 @@ function buildAndAudit({ bidders, decls = [], dis = [], links }) {
out = execFileSync(
'node',
['--import', path.join(HERE, 'register-ts.mjs'), path.join(HERE, 'audit.mjs')],
- { cwd: ROOT, env: { ...process.env, CACBG_DB: DB }, encoding: 'utf8', stdio: 'pipe' },
+ {
+ cwd: ROOT,
+ env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: staging },
+ encoding: 'utf8',
+ stdio: 'pipe',
+ },
);
} catch (e) {
threw = true; // execFileSync throws on non-zero exit (a hard finding fired)
@@ -73,8 +89,9 @@ test('A_eik behind a colliding name, backed by a real ЕИК+name double-lock, P
// the declarant wrote BOTH the ЕИК and the фирма → the double-lock the loader required
dis: [`'d1','„ОБЩ" ЕООД, ЕИК 100000001'`],
links: [
- `'il1','p1|100000001','p1','100000001','${KEY}','declared_eik','A_eik','b1','owns',0,1000,'published'`,
+ `'il1','p1|100000001','p1','100000001','${KEY}','declared_eik','confirmed','b1','owns',0,1000,'published'`,
],
+ seals: [`'p1|100000001','confirmed',NULL,'eik','2026-08-05','tr-rules-1','live'`],
});
assert.equal(
threw,
@@ -93,8 +110,9 @@ test('A_eik published on a ЕИК that is NOT a winner bearing the name key →
dis: [`'d1','„ОБЩ" ЕООД, ЕИК 300000003'`],
// entity_key claims the colliding name, but the published eik/bidder is the unrelated 300000003
links: [
- `'il1','p1|300000003','p1','300000003','${KEY}','declared_eik','A_eik','b3','owns',0,1000,'published'`,
+ `'il1','p1|300000003','p1','300000003','${KEY}','declared_eik','confirmed','b3','owns',0,1000,'published'`,
],
+ seals: [`'p1|300000003','confirmed',NULL,'eik','2026-08-05','tr-rules-1','live'`],
});
assert.equal(threw, true, 'a stray-ЕИК A_eik link must fail the gate');
assert.equal(
@@ -110,8 +128,9 @@ test('A_eik with no declaration carrying its ЕИК+name → A_eik_no_provenance
decls: [`'d1','p1'`],
dis: [`'d1','нещо съвсем друго без ЕИК'`], // no ЕИК, no фирма → the double-lock cannot be re-proven
links: [
- `'il1','p1|100000001','p1','100000001','${K('„ОБЩ" ЕООД')}','declared_eik','A_eik','b1','owns',0,1000,'published'`,
+ `'il1','p1|100000001','p1','100000001','${K('„ОБЩ" ЕООД')}','declared_eik','confirmed','b1','owns',0,1000,'published'`,
],
+ seals: [`'p1|100000001','confirmed',NULL,'eik','2026-08-05','tr-rules-1','live'`],
});
assert.equal(
threw,
@@ -130,9 +149,325 @@ test('a NON-A_eik (name-based) colliding link STILL fails A_multi_eik — the na
bidders: COLLIDING_BIDDERS,
// exact_name_key published on a name that maps to 2 ЕИК — exactly what the gate must keep rejecting
links: [
- `'il1','p1|100000001','p1','100000001','${KEY}','exact_name_key','B_distinctive','b1','owns',0,1000,'published'`,
+ `'il1','p1|100000001','p1','100000001','${KEY}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-05','tr-rules-1','live'`,
],
});
assert.equal(threw, true, 'a name-based colliding published link must still fail');
assert.equal(/A_multi_eik/.test(out), true, 'invariant A still fires for non-A_eik links');
});
+
+// ── C: evidence honesty (#279, ADR-0033) ─────────────────────────────────────────────────────────
+// The axis that used to re-derive name distinctiveness now re-derives the PUBLISHING rule, because the
+// publishing rule changed: identity rests on a registry fact, and nameDistinctiveness survives only as
+// a withholding filter inside the loader. Re-checking distinctiveness here would assert a rule that is
+// no longer in force — a test that passes while guarding nothing.
+test('a published link with NO evidence seal is a hard finding', () => {
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [], // the whole point: nothing sealed it
+ });
+ assert.equal(threw, true, 'publishing without evidence must fail the gate');
+ assert.equal(/C_no_evidence/.test(out), true, out);
+});
+
+test('a link published on a WITHHOLDING rung is a hard finding', () => {
+ // bar_joint_stock / unknown / outside_tr never publish. If one ever reaches status='published', the
+ // loader and the seal disagree and a claim is on the surface that no evidence supports.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','unknown','b1','owns',0,1000,'published'`,
+ ],
+ seals: [`'p1|100000001','unknown',NULL,NULL,'2026-08-05','tr-rules-1','live'`],
+ });
+ assert.equal(threw, true);
+ assert.equal(/C_withholding_evidence/.test(out), true, out);
+});
+
+test('a tier that disagrees with its own seal is a hard finding', () => {
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [`'p1|100000001','confirmed',NULL,'eik','2026-08-05','tr-rules-1','live'`],
+ });
+ assert.equal(threw, true);
+ assert.equal(/C_tier_evidence_mismatch/.test(out), true, out);
+});
+
+test('a matched_fact outside the closed vocabulary is a hard finding — the name-leak rail', () => {
+ // #279 §9: the deed's names are read only to produce a boolean and must never reach a served column.
+ // A schema cannot enforce a vocabulary, so the audit does. This is the shape a leak would take.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','ИВАН ПЕТРОВ ТЕСТОВ','2026-08-05','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, true, 'a name in matched_fact must fail the gate');
+ assert.equal(/C_matched_fact_shape/.test(out), true, out);
+});
+
+// …and the rail must catch the shape a leak would ACTUALLY take. `seat:` is a legitimate member
+// of the vocabulary, so an unbounded `seat:[\p{Lu} -]+` admits `seat:ИВАН ПЕТРОВ ГЕОРГИЕВ` — a full
+// three-part Bulgarian name (ЗГР чл. 9) wearing the prefix of a fact we allow. That is precisely the
+// value the rail exists to reject, and it is the one a mis-split of the seat field would produce.
+// A Bulgarian settlement is one or two tokens („СОФИЯ", „ВЕЛИКО ТЪРНОВО", „ГЕНЕРАЛ ТОШЕВО"); the
+// three-part name is exactly three. The bound is deliberately tight: a rarer 3-token seat trips the
+// audit and a human adjudicates, which is the correct direction for a rail whose failure mode is
+// publishing somebody's name.
+test('a THREE-TOKEN seat is a name shape, not a settlement — the rail must catch it', () => {
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','confirmed','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','confirmed',NULL,'seat:ИВАН ПЕТРОВ ГЕОРГИЕВ','2026-08-05','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, true, 'a three-part name behind seat: must fail the gate');
+ assert.equal(/C_matched_fact_shape/.test(out), true, out);
+});
+
+test('a real two-token settlement still passes — the bound must not empty the seat rung', () => {
+ const { threw } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','confirmed','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','confirmed',NULL,'seat:ВЕЛИКО ТЪРНОВО','2026-08-05','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, false, 'ВЕЛИКО ТЪРНОВО is a settlement and must survive the rail');
+});
+
+test('a well-formed seal passes every C axis (positive control)', () => {
+ // Without this, all four negatives above would still pass if the axes fired unconditionally.
+ const { threw } = buildAndAudit({
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-05','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, false, 'a correctly sealed link must not fail the gate');
+});
+
+// ── D. Monotonicity (ADR-0033 decision 6) ────────────────────────────────────────────────────────
+// #279 §8 asked for a seal kept „forever" and strictly-additive recomputation. ADR-0033 showed that is
+// false (labels flip, the cache expires, a court can annul an entry) and replaced the STORE with a
+// GATE: seals are re-derived every run, and the audit compares the current published set against the
+// pre-wipe export. A link that vanishes under an UNCHANGED rules_version is a hard finding — nothing
+// about the rules changed, so its disappearance is a regression, not a decision.
+//
+// Why this needs its own gate and ship's count floor cannot serve: assertShipFloor checks a COUNT.
+// A one-for-one swap — one link lost, one gained — leaves the count identical and the floor silent
+// while a true published link is dropped. Only a per-key comparison sees it.
+const SEALED = [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-05','tr-rules-1','live'`,
+];
+const ONE_LINK = {
+ bidders: [`'b1','УНИК ТЕХ 7 ЕООД','100000001',1`, `'b2','ВТОРА ФИРМА ЕООД','200000002',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('УНИК ТЕХ 7 ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: SEALED,
+};
+
+test('D — a previously published link that vanished under an UNCHANGED rules_version is a hard finding', () => {
+ const { threw, out } = buildAndAudit({
+ ...ONE_LINK,
+ snapshot: [
+ { link_key: 'p1|100000001', rules_version: 'tr-rules-1' },
+ { link_key: 'p9|200000002', rules_version: 'tr-rules-1' }, // published last run, gone now
+ ],
+ });
+ assert.equal(threw, true, 'a silent recall regression must fail the build');
+ assert.match(out, /D_monotonicity/);
+ assert.match(out, /p9\|200000002/);
+});
+
+test('D — the same disappearance under a CHANGED rules_version is a printed diff, not a finding', () => {
+ // Removal stays an intentional event: bumping the rules version is how you declare one.
+ const { threw, out } = buildAndAudit({
+ ...ONE_LINK,
+ snapshot: [{ link_key: 'p9|200000002', rules_version: 'tr-rules-0' }],
+ });
+ assert.equal(threw, false, 'a declared rules change must not fail the build');
+ assert.match(out, /p9\|200000002/, 'but it must still be reported');
+});
+
+test('D — no snapshot (a first run) neither fires nor crashes', () => {
+ const { threw } = buildAndAudit(ONE_LINK);
+ assert.equal(threw, false);
+});
+
+// The gate must also let the two removals ADR-0033 decision 6 actually SANCTIONS through, or it
+// deadlocks the mechanisms it points at. Both are declared events with a reviewed paper trail in git;
+// neither is a silent recall regression, and the gate exists to tell those apart.
+
+test('D — a link taken down through the ADR-0031 suppression path is a declared removal, not a regression', () => {
+ // Decision 6 names the court-annulled entry (чл. 29 ЗТРРЮЛНЦ) and wires it to ADR-0031. That path
+ // flips status published → suppressed, so the link leaves the published set with rules_version
+ // unchanged — firing the gate on the ONE removal the ADR explicitly licenses.
+ const { threw, out } = buildAndAudit({
+ ...ONE_LINK,
+ links: [
+ ...ONE_LINK.links,
+ `'il2','p9|200000002','p9','200000002','${K('ВТОРА ФИРМА ЕООД')}','exact_name_key','document','b2','owns',0,1000,'suppressed'`,
+ ],
+ snapshot: [
+ { link_key: 'p1|100000001', rules_version: 'tr-rules-1' },
+ { link_key: 'p9|200000002', rules_version: 'tr-rules-1' },
+ ],
+ });
+ assert.equal(threw, false, 'the sanctioned takedown path must not fail the build');
+ assert.match(out, /p9\|200000002/, 'but a withdrawn public claim is never silent');
+});
+
+test('D — a snapshot entry acknowledged as a corrected input is a declared removal', () => {
+ // The other sanctioned ground: the link should never have been published because its INPUT was
+ // wrong. Suppression cannot express it — correcting the input unbuilds the link, and load.mjs's B3
+ // gate then fails the build for a suppression that matched nothing. Without this the two sanctioned
+ // removals fail in opposite directions and there is no way to clear either.
+ const { threw, out } = buildAndAudit({
+ ...ONE_LINK,
+ snapshot: [
+ { link_key: 'p1|100000001', rules_version: 'tr-rules-1' },
+ { link_key: 'p9|200000002', rules_version: 'tr-rules-1', corrected: true },
+ ],
+ });
+ assert.equal(threw, false, 'an acknowledged correction must not fail the build');
+ assert.match(out, /p9\|200000002/);
+});
+
+test('D — neither escape hatch fires on its own: an unacknowledged, unsuppressed drop still hard-fails', () => {
+ // The mutation control for the two tests above. A gate that accepted every disappearance would pass
+ // both of them, and this is the assertion that says it did not.
+ const { threw } = buildAndAudit({
+ ...ONE_LINK,
+ links: [
+ ...ONE_LINK.links,
+ `'il2','p9|200000002','p9','200000002','${K('ВТОРА ФИРМА ЕООД')}','exact_name_key','document','b2','owns',0,1000,'held'`,
+ ],
+ snapshot: [
+ { link_key: 'p1|100000001', rules_version: 'tr-rules-1' },
+ { link_key: 'p9|200000002', rules_version: 'tr-rules-1' },
+ ],
+ });
+ assert.equal(
+ threw,
+ true,
+ 'held is not a sanctioned removal — the evidence simply stopped licensing it',
+ );
+});
+
+test('D positive control — an unchanged published set produces no monotonicity finding', () => {
+ // Without this, a gate that never fires would pass both negatives above.
+ const { threw, out } = buildAndAudit({
+ ...ONE_LINK,
+ snapshot: [{ link_key: 'p1|100000001', rules_version: 'tr-rules-1' }],
+ });
+ assert.equal(threw, false);
+ assert.doesNotMatch(out, /D_monotonicity/);
+});
+
+// The four axes below fire on shapes that no test exercised. Each is a hard finding — the audit is the
+// last gate before a named claim ships — so an axis that silently stopped firing would be invisible.
+
+test('A_key_missing: a link whose entity_key is in NO live bidder → hard finding', () => {
+ // The key resolved when the link was built and does not now: the winner was renamed, re-keyed or
+ // dropped from the corpus. The link still names an official against a company we can no longer find,
+ // so it must stop the run rather than ship pointing at nothing.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','РЕАЛЕН ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('ИЗЧЕЗНАЛ ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-12','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, true, 'an unresolvable entity_key must fail the gate');
+ assert.equal(/A_key_missing/.test(out), true);
+});
+
+test('A_eik_mismatch: a name-resolved key pointing at a DIFFERENT ЕИК than it resolves to → hard', () => {
+ // The libel case in its purest form: the name resolves to exactly one winner, and the link published a
+ // different company against it. Everything on the card — contracts, money, the ЕИК link — would be the
+ // wrong company's, under a real official's name.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','РЕАЛЕН ЕООД','100000001',1`, `'b2','ДРУГ ЕООД','200000002',1`],
+ links: [
+ `'il1','p1|200000002','p1','200000002','${K('РЕАЛЕН ЕООД')}','exact_name_key','document','b2','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|200000002','document','owner','role:owner:CR_F_19_L','2026-08-12','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, true);
+ assert.equal(/A_eik_mismatch/.test(out), true);
+});
+
+test('B_bidder_eik: the stored bidder row disagrees with the link ЕИК → hard finding', () => {
+ // Row integrity. The card renders the BIDDER's name and money but links out on the link's ЕИК, so a
+ // disagreement means the reader is shown one company and sent to another.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','РЕАЛЕН ЕООД','100000001',1`, `'b2','ДРУГ ЕООД','200000002',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('РЕАЛЕН ЕООД')}','exact_name_key','document','b2','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-12','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, true);
+ assert.equal(/B_bidder_eik/.test(out), true);
+});
+
+test('B_eik_invalid: publishing against a checksum-INVALID ЕИК → hard finding', () => {
+ // eik_valid=0 means the ЕИК failed its control digit, so it identifies no company at all. This axis is
+ // the rail that keeps such a row off the public surface, and nothing else re-checks it downstream.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','РЕАЛЕН ЕООД','100000001',0`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('РЕАЛЕН ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-12','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, true, 'an invalid-ЕИК publish must fail the gate');
+ assert.equal(/B_eik_invalid/.test(out), true);
+});
+
+test('the four axes above are a BOUND: a clean, valid, sealed link passes them all', () => {
+ // POSITIVE CONTROL for the whole block. Four assertions that something fails prove nothing unless the
+ // correct shape passes — an audit that flagged everything would satisfy every test above.
+ const { threw, out } = buildAndAudit({
+ bidders: [`'b1','РЕАЛЕН ЕООД','100000001',1`],
+ links: [
+ `'il1','p1|100000001','p1','100000001','${K('РЕАЛЕН ЕООД')}','exact_name_key','document','b1','owns',0,1000,'published'`,
+ ],
+ seals: [
+ `'p1|100000001','document','owner','role:owner:CR_F_19_L','2026-08-12','tr-rules-1','live'`,
+ ],
+ });
+ assert.equal(threw, false, `a clean link must pass: ${out}`);
+ for (const axis of ['A_key_missing', 'A_eik_mismatch', 'B_bidder_eik', 'B_eik_invalid'])
+ assert.equal(new RegExp(axis).test(out), false, `${axis} must not fire on a clean link`);
+});
diff --git a/scripts/cacbg/classify.mjs b/scripts/cacbg/classify.mjs
index fe979345..d066a3fa 100644
--- a/scripts/cacbg/classify.mjs
+++ b/scripts/cacbg/classify.mjs
@@ -16,6 +16,7 @@ const FORM_TOKENS = new Set([
'КД',
'СД',
'АДСИЦ',
+ 'КДА',
'КООПЕРАЦИЯ',
'ФОНДАЦИЯ',
'СДРУЖЕНИЕ',
@@ -32,6 +33,24 @@ const FORM_TOKENS = new Set([
// generic/withhold — the safe side), never fabricates a joint-stock exclusion. Operates on UPPERCASE input.
const SEAT_MARKER = /\s+(?:ГР|С|ОБЩ|ОБЛ|Ж\.К)\.\s*\S[^,]*$/u;
const hasFormToken = (s) => s.split(/[^А-ЯЁ]+/).some((t) => FORM_TOKENS.has(t));
+
+// The legal forms that TERMINATE a фирма (ЗТРРЮЛНЦ writes them as a suffix), so anything after one is a
+// seat or a qualifier — never part of the name. ЕТ/СД/КД/КООПЕРАЦИЯ/ФОНДАЦИЯ/СДРУЖЕНИЕ are deliberately
+// ABSENT: those PRECEDE the фирма („ЕТ Алекс Петров Димитров"), and truncating after them would eat the
+// name itself — turning a distinctive ЕТ into a generic one and withholding a true link.
+const SUFFIX_FORMS = new Set(['ЕООД', 'ООД', 'ЕАД', 'АД', 'АДСИЦ', 'КДА', 'ДЗЗД']);
+
+// Cut everything after the LAST фирма-terminating form token. This is the case the comma-peel and the
+// marker strip both miss: „ТРЕЙС ГРУП ХОЛД АД София" has neither a comma nor a „гр." dot, so it survived
+// both, stopped ending in its form, and defeated every end-anchored form test downstream. Token-exact
+// (never a substring), so „КАДИЕВ ГЛОБАЛ ЕООД" and „АД-ХОК ЕООД" are untouched.
+function stripAfterSuffixForm(s) {
+ const re = /[А-ЯЁ]+/gu;
+ let last = null;
+ for (let m = re.exec(s); m !== null; m = re.exec(s)) if (SUFFIX_FORMS.has(m[0])) last = m;
+ return last === null ? s : s.slice(0, last.index + last[0].length).trim();
+}
+
function stripSeatSuffix(upper) {
let s = String(upper).trim();
// Peel trailing comma-clauses right-to-left while the clause bears no legal form (i.e. it's a seat, not
@@ -44,7 +63,7 @@ function stripSeatSuffix(upper) {
) {
s = m[1].trim();
}
- return s.replace(SEAT_MARKER, '').trim();
+ return stripAfterSuffixForm(s.replace(SEAT_MARKER, '').trim());
}
/**
@@ -71,49 +90,55 @@ const norm = (s) =>
.replace(/[\s.\-–—]+/g, ' ')
.trim();
-// Joint-stock / listed legal form (АД / ЕАД / АДСИЦ) as the TRAILING form token. In BG company names the
+// Joint-stock / share-issuing legal form (АД / ЕАД / АДСИЦ / КДА) as the TRAILING form token. In BG company names the
// legal form is always the suffix, so anchor to the end (optionally followed by quotes/whitespace); a whole
// token bounded on the left by string edge, whitespace or quotes — NOT hyphens/dots, so „АД-ХОК ЕООД" (a
// hyphenated ООД name) is not misread. Anchoring to the suffix is what stops „АД ГРУП ООД" (an ООД whose
// NAME begins with the token „АД") being wrongly excluded as joint-stock — the form there is ООД.
-const JOINT_STOCK = /(?:^|[\s"„“”«»])(АД|ЕАД|АДСИЦ)[\s"„“”«»]*$/u;
+// Twinned byte-for-byte by JOINT_SUFFIX in scripts/tr/deed.mjs — the TR parser cannot import from this
+// directory without closing a cacbg↔tr cycle. A test in deed.test.mjs pins the two identical; change one
+// and that test fails rather than the two silently diverging on a legal form.
+export const JOINT_STOCK = /(?:^|[\s"„“”«»])(АД|ЕАД|АДСИЦ|КДА)[\s"„“”«»]*$/u;
+// The same four forms as whole tokens. Kept in step with JOINT_STOCK above by classify.test.mjs, and with
+// deed.mjs's JOINT_SUFFIX twin by deed.test.mjs — three spellings of one rule, all three pinned.
+const JOINT_STOCK_FORMS = new Set(['АД', 'ЕАД', 'АДСИЦ', 'КДА']);
/**
* Materiality by legal form. The public ownership surface is CLOSELY-HELD companies only (ООД/ЕООД/ЕТ/
- * КД/СД/ДЗЗД or a form-unspecified name from the closely-held table). Joint-stock forms (АД/ЕАД/АДСИЦ) are
+ * КД/СД/ДЗЗД or a form-unspecified name from the closely-held table). Joint-stock forms (АД/ЕАД/АДСИЦ/КДА,
+ * the last a командитно дружество с акции — it issues shares, so it belongs with them) are
* public-float securities — a declared parcel of listed shares is NOT a material ownership conflict, and
* presenting it as one defames (the „11 Trace shares → €88M" trap). Excludes only an explicit АД-form token,
* so it withholds rather than fabricates. @returns {boolean} true ⇒ material/closely-held.
*/
export function closelyHeldForm(name) {
- return !JOINT_STOCK.test(
- stripSeatSuffix(
- String(name ?? '')
- .normalize('NFC')
- .toUpperCase(),
- ),
- );
+ // LAST-FORM-TOKEN-WINS, not an end anchor. The anchor asked „does the name END in a joint-stock form?",
+ // which a declarant-typed cell can defeat just by appending a seat — and `stripSeatSuffix` cannot be
+ // trusted to have removed every shape of one. Asking instead „which legal form is the name's LAST?"
+ // is position-independent: a trailing seat, a stray qualifier, or nothing at all leaves the verdict
+ // unchanged, while „АД" leading („АД ГРУП ООД") or glued („АД-ХОК ЕООД") still isn't the form.
+ //
+ // This is why the predicate no longer uses JOINT_STOCK directly while deed.mjs's twin still does: that
+ // twin reads the deed envelope's `fullName` — a REGISTRY-clean name that genuinely ends in its form —
+ // whereas this one reads a free-text cell a human typed. Same rule, different input hygiene.
+ const tokens = stripSeatSuffix(
+ String(name ?? '')
+ .normalize('NFC')
+ .toUpperCase(),
+ )
+ .split(/[^А-ЯЁ]+/u)
+ .filter(Boolean);
+ const lastForm = tokens.filter((t) => FORM_TOKENS.has(t)).at(-1);
+ // No form token at all ⇒ nothing says joint-stock ⇒ material, exactly as the anchor behaved. The bar
+ // only ever fires on an EXPLICIT joint-stock form, so it withholds rather than fabricates.
+ return lastForm === undefined || !JOINT_STOCK_FORMS.has(lastForm);
}
-/** Seat proof: declared seat and winner settlement both present and equal ⇒ same entity (deterministic). */
-export function seatConfirmed(declSeat, winnerSettlement) {
- const a = norm(declSeat);
- const b = norm(winnerSettlement);
- return a.length > 0 && b.length > 0 && a === b;
-}
-
-/**
- * Publish tier for a single-winner-ЕИК match:
- * 'A_eik' — a declarant-provided ЕИК resolved the winner: the national unique identifier,
- * deterministic even behind a generic/colliding name. Assigned by the loader for
- * declared_eik matches (not this function — it needs the match method); ADR-0016.
- * 'A_seat' — seat-confirmed: deterministic, publishable even for generic names.
- * 'B_distinctive' — single-ЕИК + structurally distinctive name: publishable (disclosed heuristic).
- * 'C_hold' — generic name, no seat/ЕИК proof: withhold pending TR name-census.
- */
-export function publishTier({ seatOk, distinctiveness }) {
- if (seatOk) return 'A_seat';
- return distinctiveness === 'distinctive' ? 'B_distinctive' : 'C_hold';
-}
+// seatConfirmed() and publishTier() lived here until #279. The publish tiers they produced
+// (A_seat / B_distinctive / C_hold) are superseded by the Trade Register evidence ladder in
+// scripts/tr/evidence.mjs — identity now rests on a checkable registry fact rather than on the shape
+// of the declared name (ADR-0033, superseding ADR-0009). The seat comparison moved with it, because a
+// declared seat is now matched against the REGISTERED seat rather than against the winner row's
+// settlement column. nameDistinctiveness above survives, narrowed to an AND-gate on the weakest rung.
/**
* Temporal relation of a contract to the years a stake was declared (asset decls are annual snapshots).
diff --git a/scripts/cacbg/classify.test.mjs b/scripts/cacbg/classify.test.mjs
index 7d2bddd1..f0f971ba 100644
--- a/scripts/cacbg/classify.test.mjs
+++ b/scripts/cacbg/classify.test.mjs
@@ -2,11 +2,10 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
nameDistinctiveness,
- seatConfirmed,
- publishTier,
temporalStatus,
localityToken,
closelyHeldForm,
+ JOINT_STOCK,
} from './classify.mjs';
test('nameDistinctiveness: numbers / Latin / ≥3 words are distinctive; bare 1-2 word Cyrillic is generic', () => {
@@ -32,19 +31,6 @@ test('nameDistinctiveness: numbers / Latin / ≥3 words are distinctive; bare 1-
assert.equal(nameDistinctiveness('„ДОМИНО" ЕООД'), 'generic'); // quoted single word + form
});
-test('seatConfirmed: equal non-empty seats confirm; empty or mismatched do not', () => {
- assert.equal(seatConfirmed('Шумен', 'ШУМЕН'), true);
- assert.equal(seatConfirmed('София', 'Пловдив'), false);
- assert.equal(seatConfirmed('', 'София'), false); // sparse winner/declared seat never confirms
- assert.equal(seatConfirmed('София', ''), false);
-});
-
-test('publishTier: seat proof wins; else distinctiveness decides publish vs hold', () => {
- assert.equal(publishTier({ seatOk: true, distinctiveness: 'generic' }), 'A_seat');
- assert.equal(publishTier({ seatOk: false, distinctiveness: 'distinctive' }), 'B_distinctive');
- assert.equal(publishTier({ seatOk: false, distinctiveness: 'generic' }), 'C_hold');
-});
-
test('temporalStatus: contract within declared-year span is contemporaneous', () => {
assert.equal(temporalStatus([2020, 2021, 2022], 2021), 'contemporaneous');
assert.equal(temporalStatus([2020, 2022], 2024), 'after_last_decl');
@@ -71,6 +57,25 @@ test('closelyHeldForm: ООД/ЕООД/ЕТ material; АД/ЕАД/АДСИЦ (li
assert.equal(closelyHeldForm('АД СТИЛ ЕООД'), true);
});
+test('closelyHeldForm: КДА (командитно дружество с акции) is joint-stock and must be excluded', () => {
+ // КДА issues shares like an АД — the shareholder book is not public, so a declared parcel is neither
+ // verifiable nor necessarily material, which is the whole basis of the exclusion. It was missing from
+ // both JOINT_STOCK and FORM_TOKENS, so a КДА read as closely-held and its holder could be published
+ // as an owner. #279 rung 1 names it explicitly alongside АД and ЕАД.
+ assert.equal(closelyHeldForm('ФИНАНС КДА'), false);
+ assert.equal(closelyHeldForm('"АЛФА ИНВЕСТ" КДА'), false);
+ assert.equal(closelyHeldForm('АЛФА КДА, гр. София'), false); // seat suffix must not rescue it
+ // …and the mirror: „КДА" inside a word or as a leading token is not the form.
+ assert.equal(closelyHeldForm('КДА-ТРЕЙД ЕООД'), true);
+ assert.equal(closelyHeldForm('КДА ГРУП ООД'), true);
+});
+
+test('nameDistinctiveness: КДА counts as a legal form, not a content word', () => {
+ // FORM_TOKENS feeds the content-word count. A form token counted as content inflates distinctiveness,
+ // which is the direction that publishes prematurely.
+ assert.equal(nameDistinctiveness('ФИНАНС КДА'), 'generic');
+});
+
test('closelyHeldForm: a trailing седалище after the form does not flip an АД to closely-held (libel)', () => {
// The declarant appended the seat to the name cell. Without stripping it, the end-anchored form test
// misses the АД and returns closely-held=true → a listed-АД parcel presented as a material conflict.
@@ -86,6 +91,56 @@ test('closelyHeldForm: a trailing седалище after the form does not flip
assert.equal(closelyHeldForm('СТРОЙ, ИНВЕСТ ООД'), true); // trailing clause has ООД → closely-held
});
+test('the token set and the JOINT_STOCK regex name the SAME four forms', () => {
+ // closelyHeldForm now decides on the last form TOKEN while deed.mjs's envelope test still uses the
+ // end-anchored REGEX. Two spellings of one rule drift silently — the day one gains a form the other
+ // lacks, a joint-stock company passes one gate and is barred by the other. Pin them to each other.
+ // (deed.mjs's third spelling is pinned to the regex by deed.test.mjs:378.)
+ for (const form of ['АД', 'ЕАД', 'АДСИЦ', 'КДА']) {
+ assert.equal(JOINT_STOCK.test(`ФИРМА ${form}`), true, `regex misses ${form}`);
+ assert.equal(closelyHeldForm(`ФИРМА ${form}`), false, `token set misses ${form}`);
+ // …and the mirror: every form the regex accepts must be one the token set bars, seat or no seat.
+ assert.equal(closelyHeldForm(`ФИРМА ${form} София`), false, `token set misses ${form} + seat`);
+ }
+ // Every FORM_TOKEN that is NOT one of the four must read as closely-held.
+ for (const form of ['ЕООД', 'ООД', 'ЕТ', 'ДЗЗД', 'КД', 'СД']) {
+ assert.equal(closelyHeldForm(`ФИРМА ${form}`), true, `${form} wrongly barred`);
+ }
+});
+
+test('closelyHeldForm: a seat with NO comma and NO „гр." marker still cannot flip an АД (libel)', () => {
+ // The gap the marker/comma rules leave open. `SEAT_MARKER` requires a literal dot and the comma-peel
+ // requires a comma, so „ТРЕЙС ГРУП ХОЛД АД София" — the plainest way a declarant writes it — survives
+ // both, no longer ENDS in the form, and the end-anchored JOINT_STOCK test misses it. A listed АД then
+ // reads as closely-held: the „11 акции на Trace → €88M" trap, from the one input shape nothing strips.
+ assert.equal(closelyHeldForm('ТРЕЙС ГРУП ХОЛД АД София'), false);
+ assert.equal(closelyHeldForm('ТРЕЙС ГРУП ХОЛД АД СОФИЯ'), false);
+ assert.equal(closelyHeldForm('Транспроект ЕАД Пловдив'), false);
+ assert.equal(closelyHeldForm('НЕС АДСИЦ Варна'), false);
+ assert.equal(closelyHeldForm('АЛФА КДА София'), false);
+ // POSITIVE CONTROLS — the bar must stay a bound, not become a blanket. A predicate that always returned
+ // false would pass every assertion above; these are what distinguish the fix from that (ADR-0027).
+ assert.equal(closelyHeldForm('Вамос ООД Русе'), true); // dot-less seat on a closely-held form
+ assert.equal(closelyHeldForm('ЕНЕРДЖИ СЪПЛАЙ ЕООД Бургас'), true);
+ assert.equal(closelyHeldForm('АД ГРУП ООД'), true); // leading „АД" is not the form
+ assert.equal(closelyHeldForm('АД-ХОК ЕООД'), true); // „АД" glued by a hyphen is not a form token
+ assert.equal(closelyHeldForm('КДА ГРУП ООД'), true);
+ assert.equal(closelyHeldForm('КАДИЕВ ГЛОБАЛ ЕООД'), true); // „АД" inside a word
+ assert.equal(closelyHeldForm('ЕТ Алекс'), true); // ЕТ leads the фирма — nothing after it is a seat
+});
+
+test('nameDistinctiveness: a dot-less trailing city is not counted as a content word either', () => {
+ // Same blind spot, and here it fails toward PUBLISHING: an uncounted seat token inflates the content-word
+ // count to 3 ⇒ 'distinctive'. Since #279 rung 2 gates an uncorroborated „Документ" publish on exactly this
+ // predicate (ADR-0035), a seat read as a content word is a false company-identity claim, not just noise.
+ assert.equal(nameDistinctiveness('СТРОЙ ИНВЕСТ ООД София'), 'generic');
+ assert.equal(nameDistinctiveness('НИКАС КОМЕРС ЕООД Пловдив'), 'generic');
+ // POSITIVE CONTROLS: a genuinely ≥3-content-word фирма stays distinctive, and a leading-form ЕТ name
+ // keeps its content words — nothing after „ЕТ" is a seat, so the strip must not reach them.
+ assert.equal(nameDistinctiveness('ХИДРО СТРОЙ МОНТАЖ ЕООД София'), 'distinctive');
+ assert.equal(nameDistinctiveness('ЕТ АЛЕКС ПЕТРОВ ДИМИТРОВ'), 'distinctive');
+});
+
test('nameDistinctiveness: a trailing city is not counted as a content word (no premature publish)', () => {
// The exact over-publish case: 2 real content words + a seat token would read as 3 → distinctive.
assert.equal(nameDistinctiveness('СТРОЙ ИНВЕСТ ООД, СОФИЯ'), 'generic'); // was distinctive via +СОФИЯ
diff --git a/scripts/cacbg/guard.mjs b/scripts/cacbg/guard.mjs
index 97a326bd..9502ac96 100644
--- a/scripts/cacbg/guard.mjs
+++ b/scripts/cacbg/guard.mjs
@@ -9,8 +9,15 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
export const SCRATCH = path.join(ROOT, 'scratch', 'cacbg');
-export function assertScratchIgnored() {
- const probe = path.join('scratch', 'cacbg', '.probe');
+/**
+ * Assert that a `scratch/` tree is git-ignored, before anything writes PII into it.
+ * Parameterised rather than copied: the Trade Register leg needs the identical rail for its deed
+ * cache (owner names, company addresses — ADR-0033 decision 5), and a second copy of a safety rail
+ * drifts from the original. Existing no-argument callers are unaffected.
+ * @param {string} [subdir] directory under scratch/ to probe
+ */
+export function assertScratchIgnored(subdir = 'cacbg') {
+ const probe = path.join('scratch', subdir, '.probe');
try {
execFileSync('git', ['check-ignore', '-q', probe], { cwd: ROOT });
} catch {
diff --git a/scripts/cacbg/link-corrections.jsonl b/scripts/cacbg/link-corrections.jsonl
new file mode 100644
index 00000000..e69de29b
diff --git a/scripts/cacbg/load-ambiguous.test.mjs b/scripts/cacbg/load-ambiguous.test.mjs
index 336cfb30..01cf8f7a 100644
--- a/scripts/cacbg/load-ambiguous.test.mjs
+++ b/scripts/cacbg/load-ambiguous.test.mjs
@@ -45,12 +45,33 @@ function buildAndLoad(bidderRows) {
fs.writeFileSync(path.join(STAGING, 'holdings.jsonl'), '');
fs.writeFileSync(path.join(STAGING, 'related.jsonl'), '');
+ // An EMPTY Trade Register cache, pointed at explicitly (#279, ADR-0033). This fixture declares no
+ // holdings, so it resolves no links and the coverage gate is satisfied by an empty candidate set —
+ // but the cache FILE must still exist, because a missing one refuses the whole load.
+ //
+ // Explicit rather than defaulted, and that matters: load.mjs falls back to the repo's real
+ // scratch/tr/tr-cache.sqlite, so a test that omits TR_CACHE_DB silently runs against whatever the
+ // developer's last live crawl left behind. That is exactly how this test passed locally and failed
+ // in CI, where no such file exists.
+ const trDb = path.join(dir, 'tr-cache.sqlite');
+ new DatabaseSync(trDb).close();
+
let threw = false;
try {
execFileSync(
'node',
['--import', path.join(HERE, 'register-ts.mjs'), path.join(HERE, 'load.mjs')],
- { cwd: ROOT, env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING }, stdio: 'pipe' },
+ {
+ cwd: ROOT,
+ env: {
+ ...process.env,
+ CACBG_DB: DB,
+ CACBG_STAGING: STAGING,
+ TR_CACHE_DB: trDb,
+ TR_RAW_DIR: path.join(dir, 'tr-deeds'),
+ },
+ stdio: 'pipe',
+ },
);
} catch {
threw = true; // execFileSync throws on a non-zero exit code (a fired hard-gate)
diff --git a/scripts/cacbg/load-collision.test.mjs b/scripts/cacbg/load-collision.test.mjs
index 32991193..43fb1df4 100644
--- a/scripts/cacbg/load-collision.test.mjs
+++ b/scripts/cacbg/load-collision.test.mjs
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..', '..');
-let dir, DB, STAGING;
+let dir, DB, STAGING, TR_DB, TR_RAW;
function runLoad() {
execFileSync(
@@ -24,15 +24,93 @@ function runLoad() {
['--import', path.join(HERE, 'register-ts.mjs'), path.join(HERE, 'load.mjs')],
{
cwd: ROOT,
- env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING },
+ env: {
+ ...process.env,
+ CACBG_DB: DB,
+ CACBG_STAGING: STAGING,
+ TR_CACHE_DB: TR_DB,
+ TR_RAW_DIR: TR_RAW,
+ },
stdio: 'pipe',
},
);
}
+
+/**
+ * Minimal Trade Register evidence for this fixture (#279, ADR-0033). Publishing now rests on a registry
+ * fact, so a loader test without a cache would only ever exercise the fail-closed path. Each winner's
+ * deed names its own declarant as съдружник, which is the „Документ" rung.
+ */
+function buildTrCache(owners) {
+ fs.mkdirSync(TR_RAW, { recursive: true });
+ const cache = new DatabaseSync(TR_DB);
+ cache.exec(`CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY, status TEXT NOT NULL, http_status INTEGER, fetched_at TEXT NOT NULL,
+ raw_path TEXT, body_sha256 TEXT, legal_form_code INTEGER, legal_form_verdict TEXT,
+ seat_normalized TEXT, seat_entry_date TEXT, latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1, outside_reason TEXT)`);
+ for (const [eik, spec] of Object.entries(owners)) {
+ const { name, seat } = spec;
+ const deed = {
+ uic: eik,
+ fullName: '"ФИКС" ЕООД',
+ legalForm: 4,
+ sections: [
+ {
+ subDeeds: [
+ {
+ groups: [
+ {
+ fields: [
+ {
+ nameCode: 'CR_F_19_L',
+ htmlData: `
${name}
`,
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ // The REGISTERED seat, matching what this official declared. Both фирми here are
+ // generic („КОМПАНИЯ ЕДНО/ДВЕ" — two content words), so under ADR-0035 a name match
+ // alone cannot establish which company was declared; the agreeing seat is what does.
+ // Without it this fixture would exercise the withholding path instead of the
+ // cross-folder attribution it exists to test.
+ {
+ nameCode: 'CR_F_5_L',
+ htmlData: `
Държава: БЪЛГАРИЯ Населено място: гр. ${seat}
`,
+ fieldEntryNumber: '20110502101008',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ fs.writeFileSync(path.join(TR_RAW, `${eik}.json`), JSON.stringify(deed));
+ cache
+ .prepare(
+ 'INSERT OR REPLACE INTO deeds(eik,status,http_status,fetched_at,raw_path,legal_form_code,legal_form_verdict,latest_own_entry_date) VALUES(?,?,?,?,?,?,?,?)',
+ )
+ .run(
+ eik,
+ 'fetched',
+ 200,
+ '2026-08-05T00:00:00Z',
+ `${eik}.json`,
+ 4,
+ 'closely_held',
+ '2011-05-02',
+ );
+ }
+ cache.close();
+}
const open = () => new DatabaseSync(DB, { readOnly: true });
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cacbg-collision-'));
+ TR_DB = path.join(dir, 'tr-cache.sqlite');
+ TR_RAW = path.join(dir, 'tr-deeds');
DB = path.join(dir, 'fixture.sqlite');
STAGING = path.join(dir, 'staging');
fs.mkdirSync(STAGING, { recursive: true });
@@ -45,7 +123,7 @@ before(() => {
CREATE TABLE contracts(id TEXT PRIMARY KEY, tender_id TEXT, bidder_id TEXT, signed_at TEXT, amount_eur REAL);
INSERT INTO authorities VALUES ('auth:1','ВЕДОМСТВО ТЕСТ');
INSERT INTO tenders VALUES ('t1','auth:1'),('t2','auth:1');
- -- Two distinct seat-confirmed single-ЕИК winners → both publish (A_seat), no ambiguity.
+ -- Two distinct single-ЕИК winners, each named in its own deed AND seat-corroborated → both publish.
INSERT INTO bidders VALUES ('eik:100000001','КОМПАНИЯ ЕДНО ЕООД','100000001',1,'София');
INSERT INTO bidders VALUES ('eik:200000002','КОМПАНИЯ ДВЕ ЕООД','200000002',1,'Пловдив');
INSERT INTO contracts VALUES ('c1','t1','eik:100000001','2021-05-01',50000);
@@ -92,6 +170,11 @@ before(() => {
holdings.map((h) => JSON.stringify(h)).join('\n') + '\n',
);
fs.writeFileSync(path.join(STAGING, 'related.jsonl'), '');
+
+ buildTrCache({
+ 100000001: { name: 'ИВАН ПЪРВИ ТЕСТОВ', seat: 'София' },
+ 200000002: { name: 'ПЕТЪР ВТОРИ ПРОБЕН', seat: 'Пловдив' },
+ });
});
after(() => fs.rmSync(dir, { recursive: true, force: true }));
diff --git a/scripts/cacbg/load-divestment-nonwinner.test.mjs b/scripts/cacbg/load-divestment-nonwinner.test.mjs
index 226be07c..fac967b5 100644
--- a/scripts/cacbg/load-divestment-nonwinner.test.mjs
+++ b/scripts/cacbg/load-divestment-nonwinner.test.mjs
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..', '..');
-let dir, DB, STAGING;
+let dir, DB, STAGING, TR_DB, TR_RAW;
function runLoad() {
execFileSync(
@@ -25,15 +25,86 @@ function runLoad() {
['--import', path.join(HERE, 'register-ts.mjs'), path.join(HERE, 'load.mjs')],
{
cwd: ROOT,
- env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING },
+ env: {
+ ...process.env,
+ CACBG_DB: DB,
+ CACBG_STAGING: STAGING,
+ TR_CACHE_DB: TR_DB,
+ TR_RAW_DIR: TR_RAW,
+ },
stdio: 'pipe',
},
);
}
+
+/**
+ * Minimal Trade Register evidence for this fixture (#279, ADR-0033). Publishing now rests on a registry
+ * fact, so a loader test without a cache would only ever exercise the fail-closed path. Each winner's
+ * deed names its own declarant as съдружник — the „Документ" rung.
+ */
+function buildTrCache(owners) {
+ fs.mkdirSync(TR_RAW, { recursive: true });
+ const cache = new DatabaseSync(TR_DB);
+ cache.exec(`CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY, status TEXT NOT NULL, http_status INTEGER, fetched_at TEXT NOT NULL,
+ raw_path TEXT, body_sha256 TEXT, legal_form_code INTEGER, legal_form_verdict TEXT,
+ seat_normalized TEXT, seat_entry_date TEXT, latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1, outside_reason TEXT)`);
+ for (const [eik, names] of Object.entries(owners)) {
+ const html = []
+ .concat(names)
+ .map((n) => `
${n}
`)
+ .join(``);
+ const deed = {
+ uic: eik,
+ fullName: '"ФИКС" ЕООД',
+ legalForm: 4,
+ sections: [
+ {
+ subDeeds: [
+ {
+ groups: [
+ {
+ fields: [
+ {
+ nameCode: 'CR_F_19_L',
+ htmlData: html,
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ fs.writeFileSync(path.join(TR_RAW, `${eik}.json`), JSON.stringify(deed));
+ cache
+ .prepare(
+ 'INSERT OR REPLACE INTO deeds(eik,status,http_status,fetched_at,raw_path,legal_form_code,legal_form_verdict,latest_own_entry_date) VALUES(?,?,?,?,?,?,?,?)',
+ )
+ .run(
+ eik,
+ 'fetched',
+ 200,
+ '2026-08-05T00:00:00Z',
+ `${eik}.json`,
+ 4,
+ 'closely_held',
+ '2011-05-02',
+ );
+ }
+ cache.close();
+}
+
const open = () => new DatabaseSync(DB, { readOnly: true });
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cacbg-divest-'));
+ TR_DB = path.join(dir, 'tr-cache.sqlite');
+ TR_RAW = path.join(dir, 'tr-deeds');
DB = path.join(dir, 'fixture.sqlite');
STAGING = path.join(dir, 'staging');
fs.mkdirSync(STAGING, { recursive: true });
@@ -64,7 +135,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'T',
- person: 'Диан Дивестов',
+ person: 'Диан Иванов Дивестов',
position: '',
entity: 'ДИВ ТЕХ 5 ЕООД',
kind: 'shares',
@@ -80,7 +151,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'T',
- person: 'Диан Дивестов',
+ person: 'Диан Иванов Дивестов',
position: '',
entity: 'НЕПОБЕДИМ КОМПАНИ ООД',
kind: 'shares',
@@ -99,7 +170,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'T',
- person: 'Верен Държателев',
+ person: 'Верен Иванов Държателев',
position: '',
entity: 'ДРУГ ВИН 6 ЕООД',
kind: 'shares',
@@ -114,6 +185,13 @@ before(() => {
holdings.map((h) => JSON.stringify(h)).join('\n') + '\n',
);
fs.writeFileSync(path.join(STAGING, 'related.jsonl'), '');
+
+ buildTrCache({
+ // Диан DIVESTED, so the live deed must name somebody else — otherwise §7's reconciliation
+ // correctly overturns his declared termination and the case stops testing what it is for.
+ 100000001: 'НОВ ИВАНОВ СОБСТВЕНИК',
+ 200000002: 'ВЕРЕН ИВАНОВ ДЪРЖАТЕЛЕВ',
+ });
// filings.jsonl — one record per declaration (as extract.mjs emits it), carrying the declaration type. The
// divest horizon is built from this: Диан's 2022 assets declaration (listing only the non-winner) advances
// his assets horizon to 2022 → the 2019 ДИВ ТЕХ 5 winner stake is withdrawn. Верен has only a 2019 filing.
@@ -143,8 +221,8 @@ test('a later NON-winner ownership filing still withdraws a divested winner stak
)
.get(eik, person);
- const dian = link('100000001', 'Диан Дивестов');
- const veren = link('200000002', 'Верен Държателев');
+ const dian = link('100000001', 'Диан Иванов Дивестов');
+ const veren = link('200000002', 'Верен Иванов Държателев');
// The divested winner stake is dated to its last declaration and excluded from the public surface.
assert.equal(dian.status, 'withdrawn');
diff --git a/scripts/cacbg/load-homonym.test.mjs b/scripts/cacbg/load-homonym.test.mjs
index 395b62ed..7f38b12b 100644
--- a/scripts/cacbg/load-homonym.test.mjs
+++ b/scripts/cacbg/load-homonym.test.mjs
@@ -18,19 +18,94 @@ import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..', '..');
-let dir, DB, STAGING;
+let dir, DB, STAGING, TR_DB, TR_RAW;
function runLoad() {
execFileSync(
'node',
['--import', path.join(HERE, 'register-ts.mjs'), path.join(HERE, 'load.mjs')],
- { cwd: ROOT, env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING }, stdio: 'pipe' },
+ {
+ cwd: ROOT,
+ env: {
+ ...process.env,
+ CACBG_DB: DB,
+ CACBG_STAGING: STAGING,
+ TR_CACHE_DB: TR_DB,
+ TR_RAW_DIR: TR_RAW,
+ },
+ stdio: 'pipe',
+ },
);
}
+
+/**
+ * Minimal Trade Register evidence for this fixture (#279, ADR-0033). Publishing now rests on a registry
+ * fact, so a loader test without a cache would only ever exercise the fail-closed path. Each winner's
+ * deed names its own declarant as съдружник — the „Документ" rung.
+ */
+function buildTrCache(owners) {
+ fs.mkdirSync(TR_RAW, { recursive: true });
+ const cache = new DatabaseSync(TR_DB);
+ cache.exec(`CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY, status TEXT NOT NULL, http_status INTEGER, fetched_at TEXT NOT NULL,
+ raw_path TEXT, body_sha256 TEXT, legal_form_code INTEGER, legal_form_verdict TEXT,
+ seat_normalized TEXT, seat_entry_date TEXT, latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1, outside_reason TEXT)`);
+ for (const [eik, names] of Object.entries(owners)) {
+ const html = []
+ .concat(names)
+ .map((n) => `
${n}
`)
+ .join(``);
+ const deed = {
+ uic: eik,
+ fullName: '"ФИКС" ЕООД',
+ legalForm: 4,
+ sections: [
+ {
+ subDeeds: [
+ {
+ groups: [
+ {
+ fields: [
+ {
+ nameCode: 'CR_F_19_L',
+ htmlData: html,
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ fs.writeFileSync(path.join(TR_RAW, `${eik}.json`), JSON.stringify(deed));
+ cache
+ .prepare(
+ 'INSERT OR REPLACE INTO deeds(eik,status,http_status,fetched_at,raw_path,legal_form_code,legal_form_verdict,latest_own_entry_date) VALUES(?,?,?,?,?,?,?,?)',
+ )
+ .run(
+ eik,
+ 'fetched',
+ 200,
+ '2026-08-05T00:00:00Z',
+ `${eik}.json`,
+ 4,
+ 'closely_held',
+ '2011-05-02',
+ );
+ }
+ cache.close();
+}
+
const open = () => new DatabaseSync(DB, { readOnly: true });
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cacbg-homonym-'));
+ TR_DB = path.join(dir, 'tr-cache.sqlite');
+ TR_RAW = path.join(dir, 'tr-deeds');
DB = path.join(dir, 'fixture.sqlite');
STAGING = path.join(dir, 'staging');
fs.mkdirSync(STAGING, { recursive: true });
@@ -59,7 +134,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'ОБЩИНА СОФИЯ',
- person: 'Георги Иванов',
+ person: 'Георги Иванов Петров',
position: 'Кмет',
entity: 'ВИН ЕДНО 5 ЕООД',
kind: 'shares',
@@ -77,7 +152,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'МИНИСТЕРСТВО НА ТЕСТА',
- person: 'Георги Иванов',
+ person: 'Георги Иванов Петров',
position: 'Директор',
entity: 'ВИН ДВЕ 6 ЕООД',
kind: 'shares',
@@ -95,7 +170,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'ОБЩИНА СОФИЯ',
- person: 'Георги Иванов',
+ person: 'Георги Иванов Петров',
position: 'Кмет',
entity: 'ВИН ЕДНО 5 ЕООД',
kind: 'shares',
@@ -110,6 +185,11 @@ before(() => {
holdings.map((h) => JSON.stringify(h)).join('\n') + '\n',
);
fs.writeFileSync(path.join(STAGING, 'related.jsonl'), '');
+
+ buildTrCache({
+ 100000001: 'ГЕОРГИ ИВАНОВ ПЕТРОВ',
+ 200000002: 'ГЕОРГИ ИВАНОВ ПЕТРОВ',
+ });
});
after(() => fs.rmSync(dir, { recursive: true, force: true }));
@@ -120,7 +200,7 @@ test('same-named officials at different institutions do NOT merge into one perso
// Two DISTINCT persons named „Георги Иванов" — one per institution — never one merged identity.
const persons = db
- .prepare("SELECT id, name FROM persons WHERE name = 'Георги Иванов' ORDER BY id")
+ .prepare("SELECT id, name FROM persons WHERE name = 'Георги Иванов Петров' ORDER BY id")
.all();
assert.equal(persons.length, 2, 'two distinct namesake officials, not one merged person');
assert.notEqual(persons[0].id, persons[1].id);
diff --git a/scripts/cacbg/load.mjs b/scripts/cacbg/load.mjs
index 4dc360b2..ca7c030c 100644
--- a/scripts/cacbg/load.mjs
+++ b/scripts/cacbg/load.mjs
@@ -14,24 +14,65 @@ import { DatabaseSync } from 'node:sqlite';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+// nameDistinctiveness is deliberately NOT imported: the evidence ladder replaced it in the publish
+// path (ADR-0033). It survives in classify.mjs for the review queue, not for a publishing decision.
import {
- nameDistinctiveness,
- seatConfirmed,
- publishTier,
temporalStatus,
localityToken,
closelyHeldForm,
+ nameDistinctiveness,
} from './classify.mjs';
+import { openCache, readDeed, coverage } from '../tr/cache.mjs';
+import { TR_DB, TR_RAW, deedPath } from '../tr/paths.mjs';
+import {
+ evidenceVerdict,
+ isSealedFact,
+ reconcileTermination,
+ RULES_VERSION,
+} from '../tr/evidence.mjs';
import { companyCandidates, declaredEiks } from './extract-companies.mjs';
-import { fingerprint, loadSuppressions, SUPPRESSION_KEY_VERSION } from './suppressions.mjs';
+import {
+ fingerprint,
+ loadCorrections,
+ loadSuppressions,
+ SUPPRESSION_KEY_VERSION,
+} from './suppressions.mjs';
import { canonicalInstitution } from './institutions.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const DB = process.env.CACBG_DB || path.join(ROOT, 'data/work/backfill.sqlite');
const STAGING = process.env.CACBG_STAGING || path.join(ROOT, 'scratch/cacbg/staging');
const MIGRATION = path.join(ROOT, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// 0006 attaches the Trade Register evidence seal (#279, ADR-0033). Applied here as well as 0003
+// because this loader rebuilds the CACBG tables from the migrations on every run — a seal table
+// missing from the work DB would make every evidence write fail at ship time instead of at load.
+const MIGRATION_EVIDENCE = path.join(
+ ROOT,
+ 'packages/db/migrations/0009_interest_link_evidence.sql',
+);
const REPORT = path.join(STAGING, 'findings.md');
-const MATCHER_VERSION = 'cnk-1+classify-1'; // bump when the normalizer or classify logic changes
+// Bumped for #279: classify-2 (КДА added to the joint-stock bar) + tr-1 (identity now rests on a
+// Trade Register fact, not on name distinctiveness). RULES_VERSION versions the EVIDENCE rules
+// separately — §8's monotonicity gate keys on that one, not on this.
+const MATCHER_VERSION = 'cnk-1+classify-2+tr-1';
+const TR_CACHE_DB = process.env.TR_CACHE_DB || TR_DB;
+const TR_RAW_DIR = process.env.TR_RAW_DIR || TR_RAW;
+// A deliberate, logged override for the coverage gate below. Without it a single permanently
+// unreachable ЕИК would deadlock the pipeline forever; with it, the operator states that they know.
+const ALLOW_PARTIAL_TR = process.argv.includes('--allow-partial-tr');
+// Bootstrap mode: write the crawl's input list and stop, successfully. The decision run and the register
+// crawl now share one job (they must — the raw deeds hold third-party names and cannot travel between
+// runners), and that job has to be able to start from nothing: the list is derived from the resolved
+// corpus, so only this script can produce it, but the full run refuses without the very cache the list
+// is used to fill. Ignoring the refusal's exit code instead would erase the difference between „no cache
+// yet" and „this run is broken".
+//
+// POINT THIS AT A SCRATCH COPY OF THE WORK DB. It is not a read-only pass: reaching the candidate list
+// means rebuilding the corpus tables, so it drops and repopulates persons/declarations/declared_interests
+// and leaves interest_links EMPTY. Empty is the safe end state (the ship floor refuses it, and no link
+// can be published without evidence it never gathered), but it is not the state a subsequent real run
+// should inherit. The one thing it must never touch either way is the monotonicity snapshot — see below.
+const EMIT_CANDIDATES_ONLY = process.argv.includes('--emit-candidates');
const { companyNameKey, isMatchableKey } =
await import('../../packages/shared/src/company-name-key.ts');
@@ -55,7 +96,24 @@ const readJsonl = (f) =>
.map((l) => JSON.parse(l))
: [];
-const db = new DatabaseSync(DB);
+// Bootstrap mode works on a THROWAWAY COPY, and that is not a convenience — it is the correctness of the
+// monotonicity gate. Reaching the candidate list means rebuilding the corpus tables, which drops
+// interest_links; the pass itself never publishes, so it would leave the table EMPTY. The next real run
+// would then read that empty table as its prior-published set, write an empty snapshot, and the gate —
+// whose only job is to notice a published claim disappearing — would pass unconditionally, for ever.
+// Copying here rather than asking the caller to do it keeps the flag safe wherever it is invoked from.
+const WORK_DB = EMIT_CANDIDATES_ONLY ? `${DB}.bootstrap` : DB;
+if (EMIT_CANDIDATES_ONLY) {
+ for (const suffix of ['', '-wal', '-shm']) {
+ // -wal/-shm may legitimately be absent (a cleanly closed DB has neither); anything else must surface.
+ try {
+ fs.copyFileSync(`${DB}${suffix}`, `${WORK_DB}${suffix}`);
+ } catch (e) {
+ if (e.code !== 'ENOENT') throw e;
+ }
+ }
+}
+const db = new DatabaseSync(WORK_DB);
db.exec('PRAGMA foreign_keys=ON');
// Suppressions live in a VERSION-CONTROLLED, HMAC-fingerprinted list (ADR-0031), NOT a DB table — so a
// takedown survives a fresh-CI-runner rebuild and never ships the „who was taken down" signal to prod.
@@ -68,9 +126,91 @@ const suppressedFp = new Set(suppEntries.map((e) => e.fp));
// B3 unused-suppression gate: every listed fingerprint MUST match exactly one built link. Track which get
// used; a fingerprint that matched nothing (a stale/mis-keyed takedown) fails the build after the load loop.
const usedSuppressions = new Set();
+// Export the CURRENT published surface before anything is dropped — ADR-0033 decision 6.
+//
+// The rebuild below is total, so the previous run's published set exists only in this instant. The
+// audit compares against this file and hard-fails on a link that was published last run and is not
+// published now under an UNCHANGED rules_version: nothing licensed that removal, so it is a silent
+// recall regression. rules_version travels per key, because a link that vanished under a rules BUMP is
+// an intentional removal and must degrade to a printed diff instead.
+//
+// Held and withdrawn links are deliberately excluded: they were never a public claim, so their absence
+// next run is not a regression. On a first run the tables do not exist yet and the export is an empty
+// set — written, not skipped, so a missing file means „the loader never ran", not „nothing published".
+const priorPublished = (() => {
+ try {
+ return db
+ .prepare(
+ `SELECT il.link_key AS link_key, e.rules_version AS rules_version
+ FROM interest_links il
+ LEFT JOIN interest_link_evidence e ON e.link_key = il.link_key
+ WHERE il.status = 'published'`,
+ )
+ .all()
+ .map((r) => ({ link_key: r.link_key, rules_version: r.rules_version ?? RULES_VERSION }));
+ } catch (e) {
+ // A first run: interest_links does not exist yet. Any OTHER failure must surface — swallowing it
+ // would turn a broken export into a permanently silent gate.
+ if (!/no such table/i.test(e.message)) throw e;
+ return [];
+ }
+})();
+// Decision 6's SECOND sanctioned removal: „a correction of wrong input". A link whose input was wrong
+// should never have been published, but correcting the input UNBUILDS it — so a suppression on it
+// would match no built link and trip the B3 gate above, while doing nothing leaves a permanent hard
+// finding. The acknowledgement is therefore recorded here, against the set the gate actually compares:
+// each prior-published key whose fingerprint is listed is exported flagged, and audit.mjs reads the
+// flag as a declared removal. Fingerprinted for ADR-0031's reason — `pid|eik` in git would record which
+// named official was tied to which company for ever.
+const CORRECTIONS_LIST =
+ process.env.CACBG_CORRECTIONS_LIST || path.join(ROOT, 'scripts/cacbg/link-corrections.jsonl');
+const correctedFp = new Set(
+ loadCorrections(CORRECTIONS_LIST, SUPP_SALT, SUPPRESSION_KEY_VERSION).map((e) => e.fp),
+);
+const usedCorrections = new Set();
+const snapshot = priorPublished.map((p) => {
+ if (correctedFp.size === 0) return p;
+ const fp = fingerprint(p.link_key, SUPP_SALT);
+ if (!correctedFp.has(fp)) return p;
+ usedCorrections.add(fp);
+ return { ...p, corrected: true };
+});
+// The B3 rail, mirrored — and it matters MORE here. A stale suppression silently un-suppresses; a
+// stale acknowledgement silently pre-clears a FUTURE disappearance of that same link, which is exactly
+// the regression the gate exists to catch, with nobody having decided it. An acknowledgement is
+// one-shot by construction: once the corrected link stops being published it also stops appearing in
+// the prior set, so the entry must be deleted from the list in the same change that lands the fix.
+if (!EMIT_CANDIDATES_ONLY) {
+ const unusedCorr = [...correctedFp].filter((fp) => !usedCorrections.has(fp));
+ if (unusedCorr.length > 0) {
+ db.close();
+ throw new Error(
+ `${unusedCorr.length} correction(s) matched NO previously published link — a stale acknowledgement ` +
+ `would clear a future disappearance of that link, which is the regression this gate exists to ` +
+ `catch. Delete the entry once its fix has shipped (an acknowledgement is one-shot). ` +
+ `Unmatched fingerprints: ${unusedCorr.map((f) => f.slice(0, 12) + '…').join(', ')}`,
+ );
+ }
+}
+// Not written by the bootstrap pass, which works on a throwaway copy and has no business restating what
+// the real run is about to record.
+//
+// tmp + rename, not a bare write: a crash mid-write leaves a truncated file, and audit.mjs deliberately
+// does NOT swallow a parse failure here (only ENOENT is a legitimate first run). A torn snapshot would
+// therefore wedge every subsequent audit until a human cleared the file by hand. The raw deeds already
+// land this way; the gate's own input deserves the same.
+if (!EMIT_CANDIDATES_ONLY) {
+ const snapPath = path.join(STAGING, 'published-snapshot.json');
+ const snapTmp = `${snapPath}.tmp`;
+ fs.writeFileSync(snapTmp, JSON.stringify(snapshot, null, 2) + '\n');
+ fs.renameSync(snapTmp, snapPath);
+}
+
// Full idempotent rebuild that also picks up schema changes: drop the CACBG tables (children first —
// FK-safe) and re-apply the migration. Nothing to preserve — suppressions are external now.
for (const t of [
+ // FIRST: interest_link_evidence references interest_links, so it must go before its parent.
+ 'interest_link_evidence',
'interest_link_authorities',
'interest_links',
'declared_interests',
@@ -80,6 +220,7 @@ for (const t of [
])
db.exec(`DROP TABLE IF EXISTS ${t}`);
db.exec(fs.readFileSync(MIGRATION, 'utf8'));
+db.exec(fs.readFileSync(MIGRATION_EVIDENCE, 'utf8'));
// A link is suppressed when its fingerprint is in the list. Only compute the HMAC when the list is
// non-empty (size>0 ⇒ salt present, else the loader above threw), so the empty common path skips crypto.
const isSuppressed = (linkKey) => {
@@ -219,14 +360,41 @@ const familyMaterialByTemplate = new Map();
// declare a stake ONLY in the interests declaration). One record per declaration incl. empty / no-material ones,
// so a same-type divest-to-ZERO still advances the horizon. An absent/typeless file just yields no match ⇒ the
// link is kept (fail-safe: never withdraw on missing evidence).
+//
+// A filing whose is unreadable falls back to its FOLDER year (#279 §1.3). Dropping the record
+// instead — the previous behaviour — is not the fail-safe it resembles: an undated filing that vanishes
+// never advances the horizon, so `divested` stays false and a stake the official has since SOLD keeps
+// naming them on the public surface. That is a stale claim about a real person, which is the failure this
+// surface can least afford. The folder year is an APPROXIMATION — it is the publication year and runs
+// ahead of the declared year (migration 0003) — so it can advance the horizon by up to a year early. That
+// errs toward WITHDRAWING a claim we are no longer sure of, which is the safe direction here.
+//
+// A filing datable by NEITHER field is still ignored: the fallback dates a filing, it does not invent one.
const filingMaxByPersonType = new Map();
+let filingFolderDated = 0,
+ filingUndatable = 0;
for (const f of readJsonl(path.join(STAGING, 'filings.jsonl'))) {
if (!isMatchableKey(companyNameKey(f.person))) continue;
- const fy = yr(f.year);
- if (!Number.isFinite(fy)) continue;
+ let fy = yr(f.year);
+ if (!Number.isFinite(fy)) {
+ fy = yr(f.folder);
+ if (!Number.isFinite(fy)) {
+ // Counted, not silently dropped. A horizon we failed to advance is invisible in the output — the
+ // link simply stays up — so without this the only symptom of a corpus-wide date regression would be
+ // a surface that quietly stopped withdrawing anything.
+ filingUndatable++;
+ continue;
+ }
+ filingFolderDated++;
+ }
const k = `${personId(f.person, f.institution)}|${f.template ?? ''}`;
filingMaxByPersonType.set(k, Math.max(filingMaxByPersonType.get(k) ?? fy, fy));
}
+if (filingFolderDated > 0 || filingUndatable > 0) {
+ console.log(
+ ` filings: ${filingFolderDated} dated by FOLDER (unreadable ), ${filingUndatable} undatable (ignored — no horizon)`,
+ );
+}
db.exec('BEGIN');
for (const h of readJsonl(path.join(STAGING, 'holdings.jsonl'))) {
@@ -384,6 +552,12 @@ for (const r of readJsonl(path.join(STAGING, 'related.jsonl'))) {
db.exec('COMMIT');
// --- enrich each (person,eik) → interest_links (+ per-authority breakdown) -----------------------
+// THE WRITER of contract_count / contract_value_eur. Its join shape (contracts→tenders→authorities→
+// bidders) is mirrored by CONTRACT_JOIN in packages/db/src/queries/related-persons.ts, so that the
+// read-time subset can never exceed what was stored. `authorities` here IS projected (the per-authority
+// breakdown needs the name), unlike on the read side where it looks dead — that asymmetry is why the two
+// have to be pinned to each other rather than reasoned about separately. related-persons-sql.test.ts
+// asserts they agree; change one only by changing both, and re-baseline ADR-0033 §10 when you do.
const contractStmt = db.prepare(
"SELECT strftime('%Y', c.signed_at) yr, a.id auth_id, a.name authority, c.amount_eur eur FROM contracts c JOIN tenders t ON t.id=c.tender_id JOIN authorities a ON a.id=t.authority_id JOIN bidders b ON b.id=c.bidder_id WHERE b.eik_normalized=?",
);
@@ -393,6 +567,12 @@ const insLink = db.prepare(
const insILA = db.prepare(
'INSERT OR IGNORE INTO interest_link_authorities(link_key,authority_id,authority_name,contract_count,value_eur,own) VALUES(?,?,?,?,?,?)',
);
+// The evidence seal (#279 §8, migration 0006). Written for EVERY link, not only published ones — the
+// seals on held and withdrawn links are what let the review queue explain itself. `matched_fact` is a
+// closed vocabulary and must NEVER carry a name; the audit enforces that with a pattern check.
+const insEvidence = db.prepare(
+ 'INSERT OR REPLACE INTO interest_link_evidence(link_key,evidence_kind,registry_role,matched_fact,entry_number,entry_date,lookup_date,rules_version,live_status) VALUES(?,?,?,?,?,?,?,?,?)',
+);
// classify one authority (whose name may be a ';'-joined blob) against the official's institutions.
// exact = deterministic name equality; name_contains/locality = DISCLOSED heuristics (candidate, not proof).
const OWN_RANK = { exact: 3, name_contains: 2, locality: 1, none: 0 };
@@ -416,6 +596,90 @@ function authOwn(authorityName, instNorms, instNormsLong, locTokens) {
}
// Distinct officials who declared each company (ЕИК). A private interest has ONE owner-declarant; a
// public body's board is declared by MANY rotating members — the deterministic ex-officio tell (ADR-0019).
+// ── Trade Register evidence: the candidate set, the fail-closed gate, and the deed reader ─────────
+// Identity now rests on a checkable registry fact rather than on the shape of the declared name
+// (#279, ADR-0033). Two consequences the loader has to enforce, both fail-closed:
+//
+// 1. NO cache ⇒ throw. Publishing without evidence is precisely what this change abolishes.
+// 2. PARTIAL cache ⇒ throw. This is the silent one. An 80%-restored cache yields roughly 80
+// published links, which is ABOVE ship-related-persons.mjs's floor of 50 — so it would sail
+// through that guard, ship a decimated surface, and wipe the rest of the live links.
+//
+// The candidate set is every resolved ЕИК across ALL aggregates, not just the ones that end up
+// published: a link held for want of evidence still needs its deed to say so.
+const candidateEiks = [...new Set([...agg.values()].map((r) => r.eik))].sort();
+fs.writeFileSync(path.join(STAGING, 'candidate-eiks.txt'), candidateEiks.join('\n') + '\n');
+
+if (EMIT_CANDIDATES_ONLY) {
+ // Stop BEFORE the TR gate and before anything is written to the domain. A bootstrap pass that built
+ // links would leave a surface resting on no evidence at all, and a failure between this pass and the
+ // real one would leave that surface sitting in the work DB, shippable.
+ console.log(
+ `${candidateEiks.length} candidate ЕИК written for the crawler; stopping (--emit-candidates)`,
+ );
+ db.close();
+ for (const suffix of ['', '-wal', '-shm']) fs.rmSync(`${WORK_DB}${suffix}`, { force: true }); // the copy has served its purpose
+ process.exit(0);
+}
+
+if (!fs.existsSync(TR_CACHE_DB)) {
+ db.close();
+ throw new Error(
+ `REFUSE TO LOAD: no Trade Register cache at ${TR_CACHE_DB}. Every publishing decision now rests ` +
+ `on a registry fact (ADR-0033); without the cache there is no evidence to rest on. Run ` +
+ `scripts/tr/fetch-deeds.mjs --eiks-file ${path.join(STAGING, 'candidate-eiks.txt')} first.`,
+ );
+}
+const trCache = openCache(TR_CACHE_DB);
+const trCoverage = coverage(trCache, candidateEiks);
+console.log(
+ `TR cache: ${trCoverage.covered}/${trCoverage.wanted} covered ` +
+ `(fetched ${trCoverage.fetched}, outside ТР ${trCoverage.outsideTr}, missing ${trCoverage.missing})`,
+);
+if (trCoverage.missing > 0 && !ALLOW_PARTIAL_TR) {
+ trCache.close();
+ db.close();
+ throw new Error(
+ `REFUSE TO LOAD: the Trade Register cache covers ${trCoverage.covered} of ${trCoverage.wanted} ` +
+ `candidate ЕИК. A partial cache does not fail loudly downstream — it publishes a decimated ` +
+ `surface that still clears the ship floor and then wipes the rest of the live links. Finish ` +
+ `the crawl, or pass --allow-partial-tr to state that a smaller surface is intended.`,
+ );
+}
+
+// Deeds are read from git-ignored scratch and cached in memory for the run. The parsed deed carries
+// third-party names; they are used ONLY inside evidenceVerdict's boolean comparisons and never reach
+// a column, a log line or the report (ADR-0033 decision 5).
+// The lookup date sealed on every link: when the evidence was gathered, not when it was interpreted.
+// It is the freshness bound the methodology page has to state, so it comes from the cache rather than
+// from `now` — a re-run over an unchanged cache must not make the evidence look fresher than it is.
+const trLookupDate = (() => {
+ const row = trCache.prepare('SELECT MAX(fetched_at) m FROM deeds').get();
+ return row?.m ? String(row.m).slice(0, 10) : new Date().toISOString().slice(0, 10);
+})();
+
+const deedCache = new Map();
+function deedFor(eik) {
+ if (deedCache.has(eik)) return deedCache.get(eik);
+ const row = readDeed(trCache, eik);
+ let entry;
+ if (!row) entry = { deed: null, outsideTr: false, missing: true };
+ else if (row.status === 'outside_tr') entry = { deed: null, outsideTr: true, missing: false };
+ else {
+ // RE-DERIVED from the ЕИК through safeEik, never the stored raw_path. The cache index is written by
+ // the crawler but travels between runs — and once it does, a stored path is attacker-influenced
+ // input joined straight onto a filesystem root, which is a traversal read. purgeExpired already
+ // re-derives for exactly this reason; this was the one read that did not. deedPath also throws on a
+ // malformed ЕИК rather than quietly reading some other company's deed (R8).
+ const file = deedPath(eik, TR_RAW_DIR);
+ entry = fs.existsSync(file)
+ ? { deed: JSON.parse(fs.readFileSync(file, 'utf8')), outsideTr: false, missing: false }
+ : { deed: null, outsideTr: false, missing: true };
+ }
+ deedCache.set(eik, entry);
+ return entry;
+}
+
const declarantsByEik = new Map();
for (const rec of agg.values()) {
if (rec.scope !== 'self') continue; // ex-officio tell counts SELF declarants of a public board only
@@ -460,7 +724,20 @@ for (const rec of agg.values()) {
a.count++;
if (r.eur != null) a.value += r.eur;
}
- const seatOk = [...rec.seats].some((s) => seatConfirmed(s, rec.bidder.settlement));
+ // ── the evidence ladder replaces the publish tiers (ADR-0033 decision 1) ────────────────────────
+ // rec.seats is keyed on `pid|eik|scope`, so it already holds ONLY the seats this person declared for
+ // THIS company — which is what #279 §5 rung 3 requires: 4.9% of company-name keys carry more than one
+ // distinct declared seat, so a company-only key would let one person's seat confirm another's link.
+ const { deed, outsideTr, missing } = deedFor(rec.eik);
+ if (missing && !ALLOW_PARTIAL_TR) {
+ // Unreachable via the coverage gate above; kept as a belt-and-braces refusal so a future change
+ // that loosens the gate cannot silently publish a link with no evidence behind it.
+ trCache.close();
+ db.close();
+ throw new Error(
+ `no cached deed for ЕИК ${rec.eik} — the coverage gate should have caught this`,
+ );
+ }
// A declarant-provided ЕИК is the national unique identifier (ЗТРРЮЛНЦ) — it resolves the winner
// deterministically even behind a generic or winner-colliding name, so a declared_eik match publishes
// on its own basis (A_eik), never held for name-genericness. This is at least as certain as the seat
@@ -468,14 +745,60 @@ for (const rec of agg.values()) {
// Name-only methods (exact_name_key / extracted_name) still ride the distinctiveness/seat gate below:
// a globally non-unique winner name (e.g. „Водоснабдяване и канализация ЕАД" → 2 valid ЕИК in different
// towns) can never be name-distinctive, so it publishes only if the declared SEAT disambiguates, else held.
+ // The filters that can only WITHHOLD are retained as an AND-gate on the weakest rung only
+ // (ADR-0033 decision 2): a nationally shared company name cannot ride „Потвърдено". The stronger
+ // „Документ" rung is deliberately not gated — the register named this person in THIS company, which
+ // makes the name key moot. Near-zero recall cost, and it preserves ADR-0017's outcome.
+ // ADR-0017 carried forward, and NARROWED to what it actually held: a name backing more than one valid
+ // ЕИК cannot support a name-derived identity claim. It gates the SEAT leg of rung 3 only — never the
+ // declared-ЕИК leg (ADR-0028: the ЕИК is the identity), and never rung 2 (the register named this
+ // person in THIS company). nameDistinctiveness is deliberately NOT part of this gate: the seat rung
+ // exists precisely to rescue a generic name, so requiring distinctiveness would empty it.
const nameUnique = nameGloballyUnique(rec.key);
- const tier =
- rec.method === 'declared_eik'
- ? 'A_eik'
- : publishTier({
- seatOk,
- distinctiveness: nameUnique ? nameDistinctiveness(rec.key) : 'generic',
- });
+ // „Неизвестна" — the withholding verdict, used for every way of ending up with no usable evidence.
+ const noEvidence = () => ({
+ kind: 'unknown',
+ publishable: false,
+ registryRole: null,
+ matchedFact: null,
+ entryNumber: null,
+ entryDate: null,
+ rulesVersion: RULES_VERSION,
+ });
+ // With --allow-partial-tr the operator has accepted an incomplete cache. An uncached ЕИК then yields
+ // no evidence at all, which is „Неизвестна" — held. It must never be read as a reason to publish.
+ let verdict;
+ if (missing) verdict = noEvidence();
+ else {
+ try {
+ verdict = evidenceVerdict({
+ deed,
+ outsideTr,
+ declarantName: rec.person,
+ declaredSeats: [...rec.seats],
+ declaredEik: rec.method === 'declared_eik',
+ firstDeclaredYear: declYears.length ? Math.min(...declYears) : null,
+ scope: rec.scope,
+ nameGloballyUnique: nameUnique,
+ // ADR-0035. The resolver picked this winner by фирма; `nameGloballyUnique` above only says no OTHER
+ // WINNER shares that name, which says nothing about the register at large. So an uncorroborated
+ // rung 2 additionally asks whether the фирма is one a national twin is unlikely to share.
+ // `rec.key` is the WINNER's registered name — the canonical spelling of the company we actually
+ // looked up, which is the identity in question.
+ companyNameDistinctive: nameDistinctiveness(rec.key) === 'distinctive',
+ });
+ } catch (err) {
+ // A deed we cannot parse is a deed we cannot reason about — so this link withholds, exactly as an
+ // uncached one does, and the run continues. Failing the whole load instead would let one malformed
+ // deed out of ~400 decide the fate of every other link, and the ship floor would then refuse the
+ // reduced surface — turning a single bad payload into a total outage. Loud, per link, fail-closed.
+ console.error(
+ ` ${rec.eik}: evidence UNREADABLE — ${err instanceof Error ? err.message : err} (link held)`,
+ );
+ verdict = noEvidence();
+ }
+ }
+ const tier = verdict.kind;
const contemporaneous = [...years].some(
(cy) => temporalStatus(declYears, cy) === 'contemporaneous',
)
@@ -537,15 +860,33 @@ for (const rec of agg.values()) {
// non-surfaced class ('internal'), not 'published'. cValue can legitimately be 0 with cCount>0 (contracts
// whose amount is unknown/NULL) — that is a real conflict of unknown value, so gate on COUNT, not value.
const surfaces = (iClass === 'private_ownership' || iClass === 'family_ownership') && cCount > 0;
+ // §7: „terminated" is an inference from SILENCE, and its commonest cause is a finished mandate, not a
+ // sale. Before ADR-0021 E11's withdrawal takes effect, a terminated OWN stake is reconciled against the
+ // live deed: a person still registered as an owner has not divested. Family stakes are never reconciled
+ // — the registered owner there is the relative, whose name we neither store nor check.
+ // PHASE 1 uses only `terminated`; the „и към днешна дата" label is computed and deliberately not
+ // rendered (ADR-0033 decision 4 — it asserts a present tense behind an LIA addendum).
+ const recon =
+ divested && rec.scope === 'self'
+ ? reconcileTermination({ deed, declarantName: rec.person, scope: rec.scope })
+ : { terminated: divested, label: null };
+ const terminatedEffective = recon.terminated;
+
+ // Order matters and differs from the old ladder: `internal` is now decided BEFORE `published`, so a
+ // non-surfaced class (ex-officio board, management-only, zero-contract) can never land in `held`.
+ // `held` is the REVIEW QUEUE, and its population is exactly the evidence rungs that withhold —
+ // bar_joint_stock, unknown and outside_tr.
const status = isSuppressed(linkKey)
? 'suppressed'
- : divested
- ? 'withdrawn'
- : tier === 'C_hold'
- ? 'held'
- : surfaces
- ? 'published'
- : 'internal';
+ : verdict.kind === 'refuted'
+ ? 'withdrawn' // §5.4 — own stakes only; evidence.mjs refuses to refute a family stake
+ : terminatedEffective
+ ? 'withdrawn'
+ : !surfaces
+ ? 'internal'
+ : verdict.publishable
+ ? 'published'
+ : 'held';
const yrs = [...years];
insLink.run(
`il:${linkKey}`,
@@ -570,6 +911,35 @@ for (const rec of agg.values()) {
yrs.length ? String(Math.max(...yrs)) : null,
status,
);
+ // live_status is RE-DERIVED every run and never treated as part of the permanent seal (ADR-0033 R3):
+ // it asserts a present tense whose freshness is bounded by the cache refresh cycle.
+ const liveStatus = !terminatedEffective
+ ? recon.label === 'owner_today'
+ ? 'terminated_owner_still'
+ : 'live'
+ : recon.label === 'manager_today'
+ ? 'terminated_manager_still'
+ : 'terminated';
+ // Refuse at WRITE time, not only in the post-hoc audit. The audit runs after the whole domain is
+ // built; by then the name is already in a table, and a run that dies on a suppressed-by-default axis
+ // could ship it. `matched_fact` is the one sealed column derived from a deed's text, so it is the one
+ // place a third-party name can reach a served row — the rail belongs where the value is produced.
+ if (!isSealedFact(verdict.matchedFact))
+ throw new Error(
+ `REFUSE TO SEAL: matched_fact for ${linkKey} is outside the closed vocabulary — a name may have ` +
+ `leaked out of a deed (#279 §9, ADR-0033 decision 5)`,
+ );
+ insEvidence.run(
+ linkKey,
+ verdict.kind,
+ verdict.registryRole,
+ verdict.matchedFact,
+ verdict.entryNumber,
+ verdict.entryDate,
+ trLookupDate,
+ verdict.rulesVersion,
+ liveStatus,
+ );
for (const [auth_id, a] of perAuth)
insILA.run(linkKey, auth_id, a.name, a.count, a.value || null, a.own);
}
@@ -686,6 +1056,21 @@ const S = {
r.n,
]),
),
+ // Every evidence rung's count, publishing and withholding alike (ADR-0033 decision 1). `publish_tier`
+ // IS the verdict kind since #279.
+ by_evidence_kind: Object.fromEntries(
+ q('SELECT publish_tier, COUNT(*) n FROM interest_links GROUP BY publish_tier').map((r) => [
+ r.publish_tier,
+ r.n,
+ ]),
+ ),
+ // ADR-0035's residual: links where the register named this person in the resolved company but nothing
+ // established that the company is the declared one. This is the number F8's hand-labelled sample reads
+ // to decide whether the distinctiveness gate tightens to strict ЕИК/seat corroboration. Reported
+ // separately from the map above because it is a DECISION input, not just a tally.
+ document_uncorroborated: one(
+ "SELECT COUNT(*) n FROM interest_links WHERE publish_tier='document_uncorroborated'",
+ ).n,
ambiguous_name_keys: ambiguousKeys.length,
ambiguous_name_key_examples: ambiguousKeys,
noMatch,
diff --git a/scripts/cacbg/load.test.mjs b/scripts/cacbg/load.test.mjs
index d795bf59..987cbdd8 100644
--- a/scripts/cacbg/load.test.mjs
+++ b/scripts/cacbg/load.test.mjs
@@ -10,11 +10,13 @@ import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { fingerprint } from './suppressions.mjs';
+// The seal vocabulary is asserted with the PRODUCTION predicate, imported from the module that writes it.
+import { isSealedFact } from '../tr/evidence.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..', '..');
const SUPP_SALT = 'test-salt-9f3a'; // stand-in for the CI secret SUPPRESSION_SALT
-let dir, DB, STAGING;
+let dir, DB, STAGING, TR_DB, TR_RAW;
function runLoad(extraEnv = {}) {
execFileSync(
@@ -22,17 +24,105 @@ function runLoad(extraEnv = {}) {
['--import', path.join(HERE, 'register-ts.mjs'), path.join(HERE, 'load.mjs')],
{
cwd: ROOT,
- env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING, ...extraEnv },
+ env: {
+ ...process.env,
+ CACBG_DB: DB,
+ CACBG_STAGING: STAGING,
+ TR_CACHE_DB: TR_DB,
+ TR_RAW_DIR: TR_RAW,
+ ...extraEnv,
+ },
stdio: 'pipe',
},
);
}
+
+/**
+ * Build a Trade Register cache + raw deeds covering the fixture's winners.
+ *
+ * `spec[eik]` describes one deed: `owners` / `managers` are full names placed in SEPARATE registry
+ * entities (so the entity-boundary rule is exercised end to end), `form` is the numeric legalForm and
+ * `suffix` the ЗТРРЮЛНЦ form on fullName. Anything omitted from `spec` is still cached — as a deed
+ * naming somebody else — because the loader must FAIL CLOSED on a cache that does not cover every
+ * candidate, and a test that silently left ЕИК uncovered would exercise that path by accident.
+ */
+function buildTrCache(dbFile, rawDir, spec = {}, { omit = [] } = {}) {
+ fs.mkdirSync(rawDir, { recursive: true });
+ const cache = new DatabaseSync(dbFile);
+ cache.exec(`CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY, status TEXT NOT NULL, http_status INTEGER, fetched_at TEXT NOT NULL,
+ raw_path TEXT, body_sha256 TEXT, legal_form_code INTEGER, legal_form_verdict TEXT,
+ seat_normalized TEXT, seat_entry_date TEXT, latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1, outside_reason TEXT)`);
+ const src = new DatabaseSync(DB, { readOnly: true });
+ const eiks = src
+ .prepare('SELECT eik_normalized e FROM bidders WHERE eik_normalized IS NOT NULL')
+ .all()
+ .map((r) => r.e);
+ src.close();
+
+ const container = (t) =>
+ `
${t}
`;
+ const joinEntities = (names) => names.map(container).join(``);
+
+ for (const eik of eiks) {
+ if (omit.includes(eik)) continue;
+ const d = spec[eik] ?? {};
+ if (d.outsideTr) {
+ cache
+ .prepare(
+ 'INSERT OR REPLACE INTO deeds(eik,status,fetched_at,outside_reason) VALUES(?,?,?,?)',
+ )
+ .run(eik, 'outside_tr', '2026-08-05T00:00:00Z', 'HTTP 200, empty body');
+ continue;
+ }
+ const fields = [];
+ const push = (nameCode, names, entryDate) =>
+ names?.length &&
+ fields.push({
+ nameCode,
+ htmlData: joinEntities(names),
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: `${entryDate ?? '2011-05-02'}T00:00:00`,
+ });
+ push('CR_F_19_L', d.owners ?? ['НЯКОЙ ДРУГ СОБСТВЕНИК'], d.ownEntryDate);
+ push('CR_F_7_L', d.managers, d.ownEntryDate);
+ if (d.seat) push('CR_F_5_L', [`Населено място: ${d.seat}`], d.seatEntryDate ?? d.ownEntryDate);
+ const deed = {
+ uic: eik,
+ fullName: `"ФИКС" ${d.suffix ?? 'ООД'}`,
+ legalForm: d.form ?? 4,
+ sections: [{ subDeeds: [{ groups: [{ fields }] }] }],
+ };
+ fs.writeFileSync(path.join(rawDir, `${eik}.json`), JSON.stringify(deed));
+ cache
+ .prepare(
+ `INSERT OR REPLACE INTO deeds(eik,status,http_status,fetched_at,raw_path,legal_form_code,
+ legal_form_verdict,seat_normalized,latest_own_entry_date)
+ VALUES(?,?,?,?,?,?,?,?,?)`,
+ )
+ .run(
+ eik,
+ 'fetched',
+ 200,
+ '2026-08-05T00:00:00Z',
+ `${eik}.json`,
+ d.form ?? 4,
+ d.suffix && /АД|КДА/.test(d.suffix) ? 'joint_stock' : 'closely_held',
+ d.seat ? d.seat.replace(/^гр\.\s*/, '').toUpperCase() : null,
+ d.ownEntryDate ?? '2011-05-02',
+ );
+ }
+ cache.close();
+}
const open = () => new DatabaseSync(DB, { readOnly: true });
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cacbg-load-'));
DB = path.join(dir, 'fixture.sqlite');
STAGING = path.join(dir, 'staging');
+ TR_DB = path.join(dir, 'tr-cache.sqlite');
+ TR_RAW = path.join(dir, 'tr-deeds');
fs.mkdirSync(STAGING, { recursive: true });
// minimal slice of the winner schema that load.mjs joins
@@ -82,6 +172,22 @@ before(() => {
-- stake at all. The empty filing advances his horizon past 2019 → the 2019 stake is withdrawn.
INSERT INTO bidders VALUES ('eik:101010104','ДИВЕСТ ЗЕРО 4 ЕООД','101010104',1,'София');
INSERT INTO contracts VALUES ('c11','t1','eik:101010104','2019-05-01',150000);
+ -- §1.3 unparseable filing YEAR: Безгодин owns ДИВЕСТ БЕЗГОД (2019), then files a later declaration whose
+ -- is unreadable while its FOLDER carries 2023. The folder must supply the horizon, or the filing
+ -- is dropped, the horizon never advances, and a sold stake stays published — a stale public claim.
+ INSERT INTO bidders VALUES ('eik:212121218','ДИВЕСТ БЕЗГОД 9 ЕООД','212121218',1,'София');
+ INSERT INTO contracts VALUES ('c17','t1','eik:212121218','2019-05-01',120000);
+ -- §1.3 positive control: Дрънкан's later filing is datable by NEITHER NOR folder. It must be
+ -- ignored, never guessed — an undatable filing is no evidence of a sale, so this stake stays published.
+ INSERT INTO bidders VALUES ('eik:232323231','ДРЪНКАН ТЕХ 10 ЕООД','232323231',1,'София');
+ INSERT INTO contracts VALUES ('c18','t1','eik:232323231','2019-05-01',110000);
+ -- ADR-0035 winner-vs-non-winner homonym: „ХОМОНИМ ТРЕЙД" is a GENERIC фирма (two content words) and the
+ -- sole WINNER holding it. Хомоним Иванов Тестов declared a stake in a company of that name — but the one
+ -- he owns never bid, so the resolver lands on this winner instead. This winner's deed happens to name a
+ -- HOMONYM (identical three tokens), which under a name-only rung 2 „proves" a link false in both halves.
+ -- Nothing corroborates the company (no declared ЕИК, no declared seat), so it must be withheld.
+ INSERT INTO bidders VALUES ('eik:242424248','ХОМОНИМ ТРЕЙД ЕООД','242424248',1,'София');
+ INSERT INTO contracts VALUES ('c19','t1','eik:242424248','2023-05-01',95000);
-- N10 canonicalization: Канонов owns КАНОН ТЕХ 5, filing „МВР" one year and the full ministry name the
-- next → ONE identity, ONE link (not a split). Distinctive name (number) → tier B publishable.
INSERT INTO bidders VALUES ('eik:131313136','КАНОН ТЕХ 5 ЕООД','131313136',1,'София');
@@ -99,6 +205,12 @@ before(() => {
-- not twice — the load-side sibling of the UI conflictHeadline per-ЕИК money dedup.
INSERT INTO bidders VALUES ('eik:181818187','ПАРТНЬОРИ 5 ЕООД','181818187',1,'София');
INSERT INTO contracts VALUES ('c15','t1','eik:181818187','2022-06-01',600000);
+ -- FAMILY positive control (#279): identical in shape to Кмет's case but the official DECLARED the
+ -- company's seat, which is what confirms the company's identity when the registered owner is the
+ -- relative whose name we never hold. Without this case „family published: 0" would be
+ -- indistinguishable from a structurally dead path (ADR-0027's false-zero lesson).
+ INSERT INTO bidders VALUES ('eik:191919199','СЕМЕЕН ДОМ ЕООД','191919199',1,'Русе');
+ INSERT INTO contracts VALUES ('c16','t4','eik:191919199','2023-07-01',180000);
`);
db.close();
@@ -128,7 +240,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'X',
- person: 'Мария Иванова',
+ person: 'Мария Иванова Петрова',
position: '',
entity: '"ГЕНЕРИК" ООД',
kind: 'shares',
@@ -145,7 +257,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'Y',
- person: 'Петър Николов',
+ person: 'Петър Иванов Николов',
position: '',
entity: 'СИЙ ЕООД',
kind: 'shares',
@@ -162,7 +274,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'Z',
- person: 'Георги Стоянов',
+ person: 'Георги Иванов Стоянов',
position: '',
entity: 'СИЙ ЕООД',
kind: 'shares',
@@ -181,7 +293,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'W',
- person: 'Стефан Колев',
+ person: 'Стефан Иванов Колев',
position: '',
entity: '"ГЕНЕРИК" ООД, ЕИК 222222229',
kind: 'shares',
@@ -198,7 +310,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'V',
- person: 'Радка Илиева',
+ person: 'Радка Иванова Илиева',
position: '',
entity: '"ГЕНЕРИК" ООД, ЕИК 333333338',
kind: 'shares',
@@ -215,7 +327,7 @@ before(() => {
template: 'interests',
category: '',
institution: 'U',
- person: 'Борис Манолов',
+ person: 'Борис Иванов Манолов',
position: 'член на съвет',
entity: 'ХОЛДИНГ 9 ЕАД',
kind: 'management',
@@ -231,7 +343,7 @@ before(() => {
template: 'interests',
category: '',
institution: 'U',
- person: 'Виктор Асенов',
+ person: 'Виктор Иванов Асенов',
position: 'член на съвет',
entity: 'ХОЛДИНГ 9 ЕАД',
kind: 'management',
@@ -249,7 +361,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'T',
- person: 'Николай Дивестов',
+ person: 'Николай Иванов Дивестов',
position: '',
entity: 'ДИВЕСТ 1 ЕООД',
kind: 'shares',
@@ -265,7 +377,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'T',
- person: 'Николай Дивестов',
+ person: 'Николай Иванов Дивестов',
position: '',
entity: 'ДИВЕСТ 2 ЕООД',
kind: 'shares',
@@ -283,7 +395,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'ОБЩИНА ТЕСТ',
- person: 'Кмет Тестов',
+ person: 'Кмет Иванов Тестов',
position: 'кмет',
entity: 'ЕВРОСТРОЙ 21 ЕООД',
kind: 'shares',
@@ -293,6 +405,24 @@ before(() => {
holderRelation: 'related',
controlHash: 'H11',
},
+ // FAMILY positive control: same as Кмет, but with the seat declared → rung 3 confirms the company.
+ {
+ folder: '2024',
+ xmlFile: 'K2.xml',
+ year: '2023',
+ template: 'assets',
+ category: '',
+ institution: 'ОБЩИНА ТЕСТ',
+ person: 'Кметица Иванова Втора',
+ position: 'кмет',
+ entity: 'СЕМЕЕН ДОМ ЕООД',
+ kind: 'shares',
+ detail: '100%',
+ timing: 'annual',
+ seat: 'Русе',
+ holderRelation: 'related',
+ controlHash: 'H11b',
+ },
// SECURITIES: Акционер holds LISTED joint-stock shares (kind securities) → excluded, no ownership link.
{
folder: '2024',
@@ -301,7 +431,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'S',
- person: 'Акционер Тестов',
+ person: 'Акционер Иванов Тестов',
position: '',
entity: 'ЛИСТЕД ТЕСТ АД',
kind: 'securities',
@@ -320,7 +450,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'N2',
- person: 'Нула Тестов',
+ person: 'Нула Иванов Тестов',
position: '',
entity: 'НУЛА ТЕХ 3 ЕООД',
kind: 'shares',
@@ -340,7 +470,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'T2',
- person: 'Пълен Дивестов',
+ person: 'Пълен Иванов Дивестов',
position: '',
entity: 'ДИВЕСТ ЗЕРО 4 ЕООД',
kind: 'shares',
@@ -350,6 +480,61 @@ before(() => {
holderRelation: 'self',
controlHash: 'H14',
},
+ // §1.3 unparseable filing year: Безгодин's 2019 stake. His later filing (in filings.jsonl, no holdings
+ // row) carries an unreadable but a 2023 FOLDER — the fallback that must withdraw this stake.
+ {
+ folder: '2020',
+ xmlFile: 'BG0.xml',
+ year: '2019',
+ template: 'assets',
+ category: '',
+ institution: 'T3',
+ person: 'Безгодин Иванов Дивестов',
+ position: '',
+ entity: 'ДИВЕСТ БЕЗГОД 9 ЕООД',
+ kind: 'shares',
+ detail: '100%',
+ timing: 'annual',
+ seat: 'гр. София',
+ holderRelation: 'self',
+ controlHash: 'H20',
+ },
+ // §1.3 positive control: Дрънкан's 2019 stake, whose only later filing is undatable (see filings.jsonl).
+ {
+ folder: '2020',
+ xmlFile: 'DR0.xml',
+ year: '2019',
+ template: 'assets',
+ category: '',
+ institution: 'T4',
+ person: 'Дрънкан Иванов Тестов',
+ position: '',
+ entity: 'ДРЪНКАН ТЕХ 10 ЕООД',
+ kind: 'shares',
+ detail: '100%',
+ timing: 'annual',
+ seat: '',
+ holderRelation: 'self',
+ controlHash: 'H21',
+ },
+ // ADR-0035: Хомоним's declared stake in a generic-named company, with NO ЕИК and NO seat declared.
+ {
+ folder: '2024',
+ xmlFile: 'HOM.xml',
+ year: '2023',
+ template: 'assets',
+ category: '',
+ institution: 'T5',
+ person: 'Хомоним Иванов Тестов',
+ position: '',
+ entity: 'ХОМОНИМ ТРЕЙД ЕООД',
+ kind: 'shares',
+ detail: '100%',
+ timing: 'annual',
+ seat: '',
+ holderRelation: 'self',
+ controlHash: 'H22',
+ },
// B4 UNKNOWN holder: the holder cell is neither confidently the declarant's own name nor a relative's
// (an ambiguous 1-token-different cell). classifyHolder → 'unknown' → this forms NO link (counted
// nowhere), so a phantom relative never enters a published family figure.
@@ -360,7 +545,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'N3',
- person: 'Двусмислен Тестов',
+ person: 'Двусмислен Иванов Тестов',
position: '',
entity: 'ДИСТИНКТ ТЕХ 7 ЕООД',
kind: 'shares',
@@ -379,7 +564,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'МВР',
- person: 'Канонов Тестов',
+ person: 'Канонов Иванов Тестов',
position: '',
entity: 'КАНОН ТЕХ 5 ЕООД',
kind: 'shares',
@@ -396,7 +581,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'Министерство на вътрешните работи',
- person: 'Канонов Тестов',
+ person: 'Канонов Иванов Тестов',
position: '',
entity: 'КАНОН ТЕХ 5 ЕООД',
kind: 'shares',
@@ -414,7 +599,7 @@ before(() => {
template: 'assets',
category: '',
institution: '',
- person: 'Безинст Тестов',
+ person: 'Безинст Иванов Тестов',
position: '',
entity: 'БЕЗИНСТ ТЕХ 6 ЕООД',
kind: 'shares',
@@ -434,7 +619,7 @@ before(() => {
template: 'interests',
category: '',
institution: 'INT',
- person: 'Интер Тестов',
+ person: 'Интер Иванов Тестов',
position: '',
entity: 'ИНТЕР ТЕХ 8 ЕООД',
kind: 'shares',
@@ -454,7 +639,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'ТЕСТ ВЕДОМСТВО',
- person: 'Алфа Партньоров',
+ person: 'Алфа Иванов Партньоров',
position: '',
entity: 'ПАРТНЬОРИ 5 ЕООД',
kind: 'shares',
@@ -471,7 +656,7 @@ before(() => {
template: 'assets',
category: '',
institution: 'ДРУГО ВЕДОМСТВО',
- person: 'Бета Партньоров',
+ person: 'Бета Иванов Партньоров',
position: '',
entity: 'ПАРТНЬОРИ 5 ЕООД',
kind: 'shares',
@@ -503,7 +688,7 @@ before(() => {
xmlFile: 'ZE1.xml',
year: '2023',
template: 'assets', // Пълен's later EMPTY ASSET filing — same type as his 2019 asset stake ⇒ divests it
- person: 'Пълен Дивестов',
+ person: 'Пълен Иванов Дивестов',
institution: 'T2',
});
// #226 (Todor B1) cross-type: Интер declares his stake ONLY in an INTERESTS declaration (2020); his later
@@ -515,13 +700,68 @@ before(() => {
xmlFile: 'INTA.xml',
year: '2023',
template: 'assets',
- person: 'Интер Тестов',
+ person: 'Интер Иванов Тестов',
institution: 'INT',
});
+ // §1.3: the divesting filing whose is UNREADABLE. `folder` carries 2023 and is the only thing that
+ // can date it. Dropping the record leaves the horizon at 2019, `divested` false, and a sold stake on the
+ // public surface — the failure direction that matters here, since the claim names a real official.
+ filings.push({
+ folder: '2023',
+ xmlFile: 'BG1.xml',
+ year: 'н/д',
+ template: 'assets',
+ person: 'Безгодин Иванов Дивестов',
+ institution: 'T3',
+ });
+ // POSITIVE CONTROL for the same fallback: a filing datable by NEITHER field must still be ignored, not
+ // guessed at. Дрън's stake stays published — an undatable filing is no evidence of a sale.
+ filings.push({
+ folder: 'архив',
+ xmlFile: 'DR1.xml',
+ year: '',
+ template: 'assets',
+ person: 'Дрънкан Иванов Тестов',
+ institution: 'T4',
+ });
fs.writeFileSync(
path.join(STAGING, 'filings.jsonl'),
filings.map((f) => JSON.stringify(f)).join('\n') + '\n',
);
+
+ // The Trade Register evidence each link now has to rest on (#279, ADR-0033). Shaped so every
+ // existing case keeps the INTENT it was written for, under the new rule rather than the old one:
+ // • a person the register names as owner/manager → „Документ"
+ // • a declared seat matching the registered seat → „Потвърдено"
+ // • a declared ЕИК → „Потвърдено" (never name-gated, ADR-0028)
+ // • nobody we can match and nothing to confirm → „Неизвестна", held
+ buildTrCache(TR_DB, TR_RAW, {
+ 111111119: { managers: ['ИВАН ПЕТРОВ ТЕСТОВ'] }, // manages → document/manager (class keeps it internal)
+ 444444447: { seat: 'гр. Бургас' }, // Петър declared Бургас → confirmed; Георги declared none → held
+ 555555556: {
+ managers: ['БОРИС ИВАНОВ МАНОЛОВ', 'ВИКТОР ИВАНОВ АСЕНОВ'],
+ suffix: 'ЕАД',
+ form: 5,
+ },
+ 666666665: { owners: ['СЪВСЕМ ДРУГ СОБСТВЕНИК'] }, // Николай absent → his divestment stands
+ 777777773: { owners: ['НИКОЛАЙ ИВАНОВ ДИВЕСТОВ'] }, // still the registered owner → document
+ 888888884: { owners: ['РОДНИНА КМЕТОВА'] }, // family: the RELATIVE owns it, not the official
+ 999999998: { suffix: 'АД', form: 5 },
+ 101010104: { owners: ['ДРУГ СОБСТВЕНИК'] }, // Пълен absent → divest-to-zero stands
+ 131313136: { owners: ['КАНОНОВ ИВАНОВ ТЕСТОВ'] },
+ 161616163: { owners: ['ИНТЕР ИВАНОВ ТЕСТОВ'] },
+ 181818187: { owners: ['АЛФА ИВАНОВ ПАРТНЬОРОВ', 'БЕТА ИВАНОВ ПАРТНЬОРОВ'] },
+ 121212129: { owners: ['НУЛА ИВАНОВ ТЕСТОВ'] },
+ 191919199: { owners: ['РОДНИНА ВТОРА'], seat: 'гр. Русе' }, // family + declared seat → confirmed
+ // Безгодин is ABSENT from the deed (so §7 reconciliation cannot reverse the divestment) but his
+ // declared seat matches the registered one, so rung 3 says „Потвърдено" and the link PUBLISHES.
+ // Only the folder-dated divestment withdraws it — making the pre-fix failure the dangerous one.
+ 212121218: { owners: ['ДРУГ СОБСТВЕНИК СЪВСЕМ'], seat: 'гр. София' },
+ 232323231: { owners: ['ДРЪНКАН ИВАНОВ ТЕСТОВ'] }, // still the owner → the undatable filing changes nothing
+ // The homonym: the deed names someone with Хомоним's exact three tokens. Rung 2 matches — and must
+ // still withhold, because nothing says this is the company he declared.
+ 242424248: { owners: ['ХОМОНИМ ИВАНОВ ТЕСТОВ'] },
+ });
});
after(() => fs.rmSync(dir, { recursive: true, force: true }));
@@ -540,7 +780,7 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// management_role never surfaces → status 'internal', NOT 'published' (a direct D1 reader must not see a
// non-surfaced official+company row labelled published; the served query also filters by interest_class).
assert.equal(ivan.status, 'internal');
- assert.equal(ivan.publish_tier, 'B_distinctive');
+ assert.equal(ivan.publish_tier, 'document'); // the register names him a manager of this company
assert.equal(ivan.relation, 'manages');
assert.equal(ivan.interest_class, 'management_role'); // manages, sole declarant → ambiguous, not headline
assert.equal(ivan.own_institution, 'exact');
@@ -557,8 +797,8 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
assert.equal(blob.value_eur, 25000);
// bare collision name (no ЕИК in text) → quarantined, Мария gets no link
- assert.equal(link('222222229', 'Мария Иванова'), undefined);
- assert.equal(link('333333338', 'Мария Иванова'), undefined);
+ assert.equal(link('222222229', 'Мария Иванова Петрова'), undefined);
+ assert.equal(link('333333338', 'Мария Иванова Петрова'), undefined);
// the only links onto the colliding ЕИК come from declared_eik (Стефан/Радка), never exact_name_key
assert.equal(
db
@@ -569,14 +809,14 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
0,
);
- const petar = link('444444447', 'Петър Николов');
- assert.equal(petar.publish_tier, 'A_seat'); // generic name rescued by seat match
+ const petar = link('444444447', 'Петър Иванов Николов');
+ assert.equal(petar.publish_tier, 'confirmed'); // declared seat == registered seat
assert.equal(petar.status, 'published');
assert.equal(petar.interest_class, 'private_ownership'); // declared a share → the headline conflict signal
// two officials manage the SAME company → deterministically classed ex-officio (public board), not private
- const boris = link('555555556', 'Борис Манолов');
- const viktor = link('555555556', 'Виктор Асенов');
+ const boris = link('555555556', 'Борис Иванов Манолов');
+ const viktor = link('555555556', 'Виктор Иванов Асенов');
assert.equal(boris.interest_class, 'ex_officio_board');
assert.equal(viktor.interest_class, 'ex_officio_board');
assert.equal(boris.relation, 'manages');
@@ -584,14 +824,14 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
assert.equal(boris.status, 'internal');
assert.equal(viktor.status, 'internal');
- const georgi = link('444444447', 'Георги Стоянов');
- assert.equal(georgi.publish_tier, 'C_hold'); // generic, no seat → held
+ const georgi = link('444444447', 'Георги Иванов Стоянов');
+ assert.equal(georgi.publish_tier, 'unknown'); // same company, but he declared no seat → nothing confirms
assert.equal(georgi.status, 'held');
// E11 divestment: Николай's 2019 stake in ДИВЕСТ 1 is superseded by a 2022 filing that omits it → withdrawn;
// his current ДИВЕСТ 2 stake stays published. A later ownership filing that drops a company ends that link.
- const gone = link('666666665', 'Николай Дивестов');
- const kept = link('777777773', 'Николай Дивестов');
+ const gone = link('666666665', 'Николай Иванов Дивестов');
+ const kept = link('777777773', 'Николай Иванов Дивестов');
assert.equal(gone.status, 'withdrawn'); // divested — excluded from the published surface
assert.equal(gone.interest_class, 'private_ownership');
assert.equal(gone.last_declared_year, '2019'); // dated to its last declaration, never asserted "current"
@@ -601,15 +841,15 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// certain ЕИК (declared_eik) behind a colliding name, no seat → the declarant-provided ЕИК is the
// national unique identifier, so identity is deterministic → publishes as A_eik, NOT held for
// name-genericness (ADR-0016; the ЕИК is at least as certain as the seat that rescues Радка below).
- const stefan = link('222222229', 'Стефан Колев');
+ const stefan = link('222222229', 'Стефан Иванов Колев');
assert.equal(stefan.match_method, 'declared_eik'); // ЕИК resolution IS certain
- assert.equal(stefan.publish_tier, 'A_eik'); // ЕИК = unique identifier → deterministic, not name-gated
+ assert.equal(stefan.publish_tier, 'confirmed'); // the declared ЕИК confirms the company, never name-gated
assert.equal(stefan.status, 'published'); // private_ownership (20% ООД share) → surfaces
// same colliding name, resolved by her declared ЕИК (with seat as extra corroboration) → the ЕИК is the
// identity, so A_eik (not A_seat); publishable. A_seat's own path stays covered by Петър above.
- const radka = link('333333338', 'Радка Илиева');
+ const radka = link('333333338', 'Радка Иванова Илиева');
assert.equal(radka.match_method, 'declared_eik');
- assert.equal(radka.publish_tier, 'A_eik');
+ assert.equal(radka.publish_tier, 'confirmed');
assert.equal(radka.status, 'published');
// FAMILY: a close relative's declared stake in a winner that sold to the official's OWN institution.
@@ -617,10 +857,17 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// relative's declared stake in a procurement winner is the same public-interest signal. class
// family_ownership, relation 'related', own_institution exact. It has real contract money (€250k, cCount>0)
// so the zero-contract gate keeps it.
- const family = link('888888884', 'Кмет Тестов');
+ const family = link('888888884', 'Кмет Иванов Тестов');
assert.equal(family.relation, 'related');
assert.equal(family.interest_class, 'family_ownership');
- assert.equal(family.status, 'published'); // ADR-0032: family surfaces like self
+ // #279 NARROWS the family surface, and this is where it shows. The registered owner of a family
+ // stake is the RELATIVE, whose name we deliberately never store (ADR-0010 item 4, ADR-0032 #2) — 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 ЕИК. Кмет declared neither, so his
+ // link is now HELD rather than published. ADR-0032's decision is untouched — family publishes on the
+ // named surface exactly like self — but it now needs the same registry evidence as everything else.
+ assert.equal(family.status, 'held');
+ assert.equal(family.publish_tier, 'unknown');
assert.equal(family.own_institution, 'exact'); // relative's company sold to the official's own institution
assert.equal(family.contemporaneous, 1);
assert.equal(family.contract_value_eur, 250000);
@@ -645,8 +892,8 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// SUM(contract_value_eur) over published links double-counts that winner — the load-side twin of the UI
// conflictHeadline bug. Prove the fixture actually exercises the collision (naive per-link sum strictly
// exceeds the per-ЕИК-deduped sum), then assert the reported totals equal the deduped figure.
- const alfa = link('181818187', 'Алфа Партньоров');
- const beta = link('181818187', 'Бета Партньоров');
+ const alfa = link('181818187', 'Алфа Иванов Партньоров');
+ const beta = link('181818187', 'Бета Иванов Партньоров');
assert.equal(alfa.status, 'published');
assert.equal(beta.status, 'published');
assert.equal(alfa.interest_class, 'private_ownership');
@@ -683,14 +930,14 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// ZERO-CONTRACT gate (I5): a distinctive winner with NO contracts is collected but never published — the
// card would read „0 договори · 0 €", which is no procurement conflict. status 'internal', not 'published'.
- const zero = link('121212129', 'Нула Тестов');
+ const zero = link('121212129', 'Нула Иванов Тестов');
assert.equal(zero.contract_count, 0);
assert.equal(zero.interest_class, 'private_ownership'); // it IS own material ownership …
- assert.equal(zero.publish_tier, 'B_distinctive'); // … and tier-B by name …
+ assert.equal(zero.publish_tier, 'document'); // … with registry evidence …
assert.equal(zero.status, 'internal'); // … but the zero-contract gate withholds it from the surface
// SECURITIES/materiality: a self holding of LISTED joint-stock shares forms NO ownership link.
- assert.equal(link('999999998', 'Акционер Тестов'), undefined);
+ assert.equal(link('999999998', 'Акционер Иванов Тестов'), undefined);
// but it is still recorded as a declared interest (census), tagged kind securities
assert.equal(
db.prepare("SELECT kind FROM declared_interests WHERE entity_raw='ЛИСТЕД ТЕСТ АД'").get().kind,
@@ -700,7 +947,7 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// B1 divest-to-ZERO: Пълен owned ДИВЕСТ ЗЕРО in 2019, then filed an EMPTY declaration in 2023 (no holdings
// row — only a filings.jsonl entry). The empty filing advances his horizon to 2023, so the 2019 stake is
// WITHDRAWN. Without the filing horizon (pre-B1) his scope-max would be 2019 and this would stay published.
- const divZero = link('101010104', 'Пълен Дивестов');
+ const divZero = link('101010104', 'Пълен Иванов Дивестов');
assert.equal(divZero.interest_class, 'private_ownership');
assert.equal(divZero.last_declared_year, '2019'); // dated to its last declaration, never asserted current
assert.equal(divZero.status, 'withdrawn'); // caught by the empty later filing (B1)
@@ -710,18 +957,47 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
// that asset-declaration silence as a sale and WITHDRAWS the stake; the per-type horizon must not — no later
// INTERESTS filing omits the company. This link must stay PUBLISHED. (Guards against dropping a true link:
// 13% of holders declare a stake only in the interests declaration.)
- const crossType = link('161616163', 'Интер Тестов');
+ // ADR-0035 — the CRITICAL, end to end. Хомоним declared a stake in „ХОМОНИМ ТРЕЙД ЕООД"; the company he
+ // actually owns never bid, so `resolveEntity` resolved his declaration to the same-named WINNER, whose
+ // deed names a person with his exact three tokens. Rung 2 matches. It must NOT publish: the register
+ // proves someone of that name owns THIS company, not that this is the company he declared. `nameGlobally-
+ // Unique` cannot catch it — it ranges over bidders, and this winner is the only bidder with the name.
+ const homonym = link('242424248', 'Хомоним Иванов Тестов');
+ assert.equal(homonym.publish_tier, 'document_uncorroborated');
+ assert.notEqual(homonym.status, 'published');
+ // The seal must record WHY it was withheld, and must not carry the role the rung refused to assert.
+ const homonymSeal = db
+ .prepare(
+ 'SELECT evidence_kind, registry_role, matched_fact FROM interest_link_evidence WHERE link_key=?',
+ )
+ .get(homonym.link_key);
+ assert.equal(homonymSeal.evidence_kind, 'document_uncorroborated');
+ assert.equal(homonymSeal.registry_role, null);
+ assert.equal(homonymSeal.matched_fact, null);
+
+ // §1.3 unparseable filing YEAR: Безгодин's later declaration has an unreadable ('н/д') but a 2023
+ // FOLDER. Dropping that record — the pre-fix behaviour — leaves his horizon at 2019, so `divested` stays
+ // false and a stake he no longer holds keeps naming him on the public surface. The folder must date it.
+ const noYear = link('212121218', 'Безгодин Иванов Дивестов');
+ assert.equal(noYear.interest_class, 'private_ownership');
+ assert.equal(noYear.status, 'withdrawn');
+ // POSITIVE CONTROL: datable by NEITHER field ⇒ ignored, not guessed. The fallback must not become a
+ // licence to invent a horizon — an undatable filing is no evidence of a sale, so this link stays up.
+ const noDate = link('232323231', 'Дрънкан Иванов Тестов');
+ assert.equal(noDate.status, 'published');
+
+ const crossType = link('161616163', 'Интер Иванов Тестов');
assert.equal(crossType.interest_class, 'private_ownership');
assert.equal(crossType.status, 'published'); // NOT withdrawn — the later asset filing is a different type
// B4 UNKNOWN holder: an ambiguous holder cell forms NO link at all (counted nowhere) — it must never
// reach the leaderboard, self or family (ADR-0032). Двусмислен gets no interest_link.
- assert.equal(link('111111119', 'Двусмислен Тестов'), undefined);
+ assert.equal(link('111111119', 'Двусмислен Иванов Тестов'), undefined);
// but the person + declared_interest are still recorded (census), and it is neither self nor family.
assert.equal(
db
.prepare(
- "SELECT COUNT(*) n FROM interest_links il JOIN persons p ON p.id=il.person_id WHERE p.name='Двусмислен Тестов'",
+ "SELECT COUNT(*) n FROM interest_links il JOIN persons p ON p.id=il.person_id WHERE p.name='Двусмислен Иванов Тестов'",
)
.get().n,
0,
@@ -738,10 +1014,10 @@ test('resolves publish/held/quarantine tiers deterministically', () => {
.n,
1,
);
- const kanon = link('131313136', 'Канонов Тестов');
+ const kanon = link('131313136', 'Канонов Иванов Тестов');
assert.equal(kanon.status, 'published'); // distinctive, private ownership, has a contract
// N10 empty-institution: an empty institution cannot distinguish homonyms → Безинст forms NO link.
- assert.equal(link('141414141', 'Безинст Тестов'), undefined);
+ assert.equal(link('141414141', 'Безинст Иванов Тестов'), undefined);
db.close();
});
@@ -772,14 +1048,17 @@ test('re-run is idempotent and honors the suppression list (contested link stays
'suppressed',
);
// idempotent: still exactly the same number of links + persons after a clean rebuild.
- // 16 links: 12 self (incl. withdrawn/held + the zero-contract 'internal' + Пълен's divest-to-zero
- // 'withdrawn' + Интер's per-type-kept published link) + 1 family + Канонов's canonicalized single link +
+ // 20 links: 15 self (incl. withdrawn/held + the zero-contract 'internal' + Пълен's divest-to-zero
+ // 'withdrawn' + Безгодин's folder-dated 'withdrawn' + Дрънкан's undatable-filing 'published' +
+ // Хомоним's ADR-0035 'document_uncorroborated' hold + Интер's per-type-kept published link) + 2 family (Кмет's, now held for want of registry evidence,
+ // and Кметица's seat-confirmed one) + Канонов's canonicalized single link +
// Алфа & Бета (two officials on one winner, ПАРТНЬОРИ 5); Мария (quarantined), Акционер (securities),
// Двусмислен (unknown holder) & Безинст (empty institution) none.
- assert.equal(db.prepare('SELECT COUNT(*) n FROM interest_links').get().n, 16);
- // 19 persons: everyone who declared a holding, incl. no-link Мария, Акционер, Двусмислен, Безинст,
- // zero-contract Нула and the two ПАРТНЬОРИ co-owners; Канонов's two institution-variant filings fold to ONE.
- assert.equal(db.prepare('SELECT COUNT(*) n FROM persons').get().n, 19);
+ assert.equal(db.prepare('SELECT COUNT(*) n FROM interest_links').get().n, 20);
+ // 23 persons: everyone who declared a holding, incl. no-link Мария, Акционер, Двусмислен, Безинст,
+ // zero-contract Нула, the two ПАРТНЬОРИ co-owners, the two §1.3 filing-date cases and Хомоним;
+ // Канонов's two institution-variant filings fold to ONE.
+ assert.equal(db.prepare('SELECT COUNT(*) n FROM persons').get().n, 23);
db.close();
});
@@ -879,3 +1158,362 @@ test('a suppression on a ROTATED key_version FAILS the build (B3 no silent salt
(err) => /key_version/.test(String(err.stderr ?? '') + String(err.message ?? '')),
);
});
+
+// ── the fail-closed evidence gates (ADR-0033 decision 7) ─────────────────────────────────────────
+// Both directions of "no evidence ⇒ no publish", because the partial case is the dangerous one: it does
+// NOT fail loudly downstream. An 80%-restored cache yields roughly 80 published links, which clears
+// ship-related-persons.mjs's floor of 50 — so it would ship a decimated surface and then wipe the rest
+// of the live links. The loader is the only place that can still tell the difference.
+test('a MISSING Trade Register cache refuses the whole load', () => {
+ const gone = path.join(dir, 'no-such-cache.sqlite');
+ assert.throws(
+ () => runLoad({ TR_CACHE_DB: gone }),
+ /REFUSE TO LOAD[\s\S]*no Trade Register cache/,
+ );
+});
+
+test('a PARTIAL cache refuses the load rather than publishing a decimated surface', () => {
+ const partialDb = path.join(dir, 'partial-cache.sqlite');
+ const partialRaw = path.join(dir, 'partial-deeds');
+ // Cover everything EXCEPT two winners — the shape a resumed-but-unfinished crawl leaves behind.
+ buildTrCache(
+ partialDb,
+ partialRaw,
+ { 111111119: { managers: ['ИВАН ПЕТРОВ ТЕСТОВ'] } },
+ { omit: ['444444447', '777777773'] },
+ );
+ assert.throws(
+ () => runLoad({ TR_CACHE_DB: partialDb, TR_RAW_DIR: partialRaw }),
+ /REFUSE TO LOAD[\s\S]*covers \d+ of \d+/,
+ );
+});
+
+test('--allow-partial-tr is the deliberate, stated override', () => {
+ // Without an override a single permanently unreachable ЕИК would deadlock the pipeline forever.
+ const partialDb = path.join(dir, 'partial-ok.sqlite');
+ const partialRaw = path.join(dir, 'partial-ok-deeds');
+ buildTrCache(partialDb, partialRaw, {}, { omit: ['121212129'] });
+ assert.doesNotThrow(() =>
+ execFileSync(
+ 'node',
+ [
+ '--import',
+ path.join(HERE, 'register-ts.mjs'),
+ path.join(HERE, 'load.mjs'),
+ '--allow-partial-tr',
+ ],
+ {
+ cwd: ROOT,
+ env: {
+ ...process.env,
+ CACBG_DB: DB,
+ CACBG_STAGING: STAGING,
+ TR_CACHE_DB: partialDb,
+ TR_RAW_DIR: partialRaw,
+ },
+ stdio: 'pipe',
+ },
+ ),
+ );
+ runLoad(); // restore the full-cache state for any later reader
+});
+
+test('the candidate ЕИК list is written for the crawler, covering held links too', () => {
+ runLoad();
+ const listed = fs
+ .readFileSync(path.join(STAGING, 'candidate-eiks.txt'), 'utf8')
+ .split('\n')
+ .filter(Boolean);
+ const db = open();
+ const all = db
+ .prepare('SELECT DISTINCT eik FROM interest_links')
+ .all()
+ .map((r) => r.eik);
+ db.close();
+ // Every resolved ЕИК, not just the published ones: a link held for want of evidence still needs a
+ // deed to explain why it is held.
+ for (const e of all) assert.ok(listed.includes(e), `${e} missing from candidate-eiks.txt`);
+ assert.equal(new Set(listed).size, listed.length, 'no duplicates — each ЕИК costs one request');
+});
+
+test('every link carries an evidence seal, and no seal carries a name', () => {
+ runLoad();
+ const db = open();
+ const links = db.prepare('SELECT link_key, publish_tier, status FROM interest_links').all();
+ const seals = db.prepare('SELECT * FROM interest_link_evidence').all();
+ assert.equal(seals.length, links.length, 'a seal for EVERY link, held and withdrawn included');
+
+ // The PRODUCTION predicate, imported from the module that WRITES the vocabulary — a restated regex
+ // here was looser than the real one and would have passed a seat token carrying a full name.
+ for (const s of seals) {
+ assert.ok(isSealedFact(s.matched_fact), s.matched_fact);
+ assert.ok(s.rules_version.length > 0);
+ assert.match(s.lookup_date, /^\d{4}-\d{2}-\d{2}$/);
+ }
+ // The rail, stated as an assertion rather than a convention. Scope it precisely: `link_key` carries
+ // the OFFICIAL's own identity by design — they are named on the public surface from their own
+ // declaration — so the rail is about everyone else. No name that exists only inside a registry deed
+ // (a co-owner, a manager, the relative who actually holds a family stake) may reach a sealed column.
+ const evidenceOnly = seals.map(({ link_key: _ignored, ...rest }) => rest);
+ const blob = JSON.stringify(evidenceOnly).toUpperCase();
+ for (const thirdParty of [
+ 'РОДНИНА', // the relative who owns the family company
+ 'СЪВСЕМ ДРУГ СОБСТВЕНИК', // a registry co-owner nobody declared
+ 'НЯКОЙ ДРУГ',
+ 'ДРУГ СОБСТВЕНИК',
+ ])
+ assert.ok(!blob.includes(thirdParty), `a third-party name reached the seal: ${thirdParty}`);
+ // …and the declarant's name must not be duplicated into the evidence fields either.
+ for (const surname of ['ТЕСТОВ', 'ДИВЕСТОВ', 'ПАРТНЬОРОВ'])
+ assert.ok(!blob.includes(surname), `a declarant name reached an evidence column: ${surname}`);
+ db.close();
+});
+
+test('a family stake publishes ONLY when the official confirmed the company themselves', () => {
+ // The positive control for the narrowest published path. A family link can never earn „Документ" —
+ // the registered owner is the relative, whose name we never hold — so it stands or falls on the seat
+ // or ЕИК the OFFICIAL declared. Without this case „family published: 0" would be indistinguishable
+ // from a structurally dead path (ADR-0027).
+ runLoad();
+ const db = open();
+ const withSeat = db
+ .prepare(
+ "SELECT il.status, il.publish_tier FROM interest_links il JOIN persons p ON p.id=il.person_id WHERE il.eik='191919199' AND p.name='Кметица Иванова Втора'",
+ )
+ .get();
+ assert.equal(withSeat.status, 'published');
+ assert.equal(withSeat.publish_tier, 'confirmed');
+ const seal = db
+ .prepare(
+ "SELECT matched_fact FROM interest_link_evidence WHERE link_key LIKE '%191919199|family'",
+ )
+ .get();
+ assert.equal(seal.matched_fact, 'seat:РУСЕ');
+ db.close();
+});
+
+test('the published surface is exported BEFORE the wipe, so the audit can gate monotonicity', () => {
+ // ADR-0033 decision 6. The loader rebuilds the CACBG tables from scratch every run, so the previous
+ // published set exists only in the instant before the DROP. Without this export the audit has
+ // nothing to compare against and the monotonicity gate can never fire — which is precisely the state
+ // review found: rules_version was written and never read.
+ // A FIRST run, on its own database: interest_links does not exist yet, so the export must be an
+ // empty set that is WRITTEN rather than skipped — a missing file has to keep meaning „the loader
+ // never ran", not „nothing was published".
+ const fresh = fs.mkdtempSync(path.join(os.tmpdir(), 'cacbg-load-first-'));
+ fs.mkdirSync(path.join(fresh, 'staging'), { recursive: true });
+ for (const f of fs.readdirSync(STAGING))
+ fs.copyFileSync(path.join(STAGING, f), path.join(fresh, 'staging', f));
+ // The base DB (bidders, contracts) comes from the main pipeline and is a precondition of the load —
+ // a genuinely empty file is not a first run, it is a broken one. So: copy the base and drop the
+ // CACBG tables, which is exactly the state before the loader has ever run against it.
+ const firstDb = path.join(fresh, 'first.sqlite');
+ fs.copyFileSync(DB, firstDb);
+ const fdb = new DatabaseSync(firstDb);
+ for (const t of [
+ 'interest_link_evidence',
+ 'interest_link_authorities',
+ 'interest_links',
+ 'declared_interests',
+ 'related_persons_internal',
+ 'declarations',
+ 'persons',
+ ])
+ fdb.exec(`DROP TABLE IF EXISTS ${t}`);
+ fdb.close();
+ runLoad({ CACBG_DB: firstDb, CACBG_STAGING: path.join(fresh, 'staging') });
+ assert.deepEqual(
+ JSON.parse(fs.readFileSync(path.join(fresh, 'staging', 'published-snapshot.json'), 'utf8')),
+ [],
+ );
+ fs.rmSync(fresh, { recursive: true, force: true });
+
+ const SNAP = path.join(STAGING, 'published-snapshot.json');
+ runLoad();
+ const db = open();
+ const published = db
+ .prepare("SELECT link_key FROM interest_links WHERE status='published'")
+ .all()
+ .map((r) => r.link_key);
+ db.close();
+ assert.ok(published.length > 0, 'fixture must publish something for this test to mean anything');
+
+ runLoad();
+ const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8'));
+ assert.deepEqual(
+ snap.map((s) => s.link_key).sort(),
+ [...published].sort(),
+ 'the export must hold exactly what was published before the wipe',
+ );
+ // rules_version travels WITH each key: the gate distinguishes „vanished under unchanged rules"
+ // (a regression) from „vanished under a rules bump" (an intentional removal), and it cannot do that
+ // from the current version alone.
+ for (const s of snap)
+ assert.ok(s.rules_version?.length > 0, 'each exported key carries its rules');
+ // Held and withdrawn links must NOT be exported — they were never a public claim, so their absence
+ // next run is not a regression to gate on.
+ assert.equal(snap.length, published.length);
+});
+
+// The crawl input and the crawl's consumer are the same script, which used to make the two workflows
+// deadlock: the decision run refused without a deed cache, the refresh run refused without a candidate
+// list, and each produced only what the other needed. Merged into one job, the job still has to be able
+// to BOOTSTRAP — produce the candidate list on a runner that has no cache yet — and it cannot do that by
+// running the full load and ignoring a non-zero exit, because that exit is also how a genuinely broken
+// run reports itself. Hence an explicit mode that stops at the list and succeeds.
+test('--emit-candidates writes the crawl list and exits 0 with NO Trade Register cache', () => {
+ const gone = path.join(dir, 'bootstrap-absent.sqlite');
+ const listFile = path.join(STAGING, 'candidate-eiks.txt');
+ fs.rmSync(listFile, { force: true });
+ assert.doesNotThrow(() =>
+ execFileSync(
+ 'node',
+ [
+ '--import',
+ path.join(HERE, 'register-ts.mjs'),
+ path.join(HERE, 'load.mjs'),
+ '--emit-candidates',
+ ],
+ {
+ cwd: ROOT,
+ env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING, TR_CACHE_DB: gone },
+ stdio: 'pipe',
+ },
+ ),
+ );
+ const listed = fs.readFileSync(listFile, 'utf8').split('\n').filter(Boolean);
+ assert.ok(listed.length > 0, 'the bootstrap must actually produce candidates');
+ assert.equal(new Set(listed).size, listed.length);
+ runLoad(); // restore the full built state for any later reader
+});
+
+function emitCandidates(trCacheDb) {
+ execFileSync(
+ 'node',
+ [
+ '--import',
+ path.join(HERE, 'register-ts.mjs'),
+ path.join(HERE, 'load.mjs'),
+ '--emit-candidates',
+ ],
+ {
+ cwd: ROOT,
+ env: { ...process.env, CACBG_DB: DB, CACBG_STAGING: STAGING, TR_CACHE_DB: trCacheDb },
+ stdio: 'pipe',
+ },
+ );
+}
+
+test('a bootstrap pass leaves the REAL work DB untouched — it runs on a throwaway copy', () => {
+ // Reaching the candidate list means rebuilding the corpus tables, and the pass never publishes, so
+ // against the real DB it would leave interest_links empty. That is the damage: not the pass itself,
+ // but what the NEXT run then reads.
+ runLoad();
+ const db = open();
+ const before = db.prepare('SELECT COUNT(*) AS n FROM interest_links').get().n;
+ db.close();
+ assert.ok(before > 0, 'the fixture must actually publish something');
+
+ emitCandidates(path.join(dir, 'bootstrap-absent-2.sqlite'));
+
+ const after = open();
+ assert.equal(
+ after.prepare('SELECT COUNT(*) AS n FROM interest_links').get().n,
+ before,
+ 'the bootstrap pass must not empty the domain it was pointed at',
+ );
+ after.close();
+ assert.equal(fs.existsSync(`${DB}.bootstrap`), false, 'the throwaway copy must be cleaned up');
+});
+
+test('the monotonicity gate still sees a prior surface AFTER a bootstrap pass', () => {
+ // The end-to-end shape of the merged workflow: real run → bootstrap (to produce the crawl list) →
+ // real run. If the bootstrap emptied interest_links, the second real run would export an EMPTY
+ // prior-published set, and the gate — whose only job is to notice a published claim disappearing —
+ // would pass unconditionally, for ever.
+ const snapshot = path.join(STAGING, 'published-snapshot.json');
+ runLoad();
+ emitCandidates(path.join(dir, 'bootstrap-absent-3.sqlite'));
+ runLoad();
+ const prior = JSON.parse(fs.readFileSync(snapshot, 'utf8'));
+ assert.ok(
+ prior.length > 0,
+ 'the snapshot went empty — the gate is now vacuous and would never fire again',
+ );
+});
+
+// ── the corrections list — ADR-0033 decision 6's second sanctioned removal ────────────────────────
+// Decision 6 licenses removal by „a rules-version bump, or a correction of wrong input". Only the
+// first was expressible. Suppression cannot carry a correction: correcting the input UNBUILDS the
+// link, and the B3 unused-suppression gate above then fails the build for a fingerprint that matched
+// nothing — so the two sanctioned removals failed in opposite directions and a real correction had no
+// path at all. The list is fingerprinted for the same reason ADR-0031's is: `pid|eik` in git would
+// record which named official was linked to which company, for ever.
+
+test('a corrections entry marks its key in the snapshot, so the audit reads a declared removal', () => {
+ runLoad();
+ const db = open();
+ const key = db
+ .prepare("SELECT link_key FROM interest_links WHERE status='published' LIMIT 1")
+ .get().link_key;
+ db.close();
+
+ const corrFile = path.join(dir, 'corr.jsonl');
+ fs.writeFileSync(
+ corrFile,
+ JSON.stringify({
+ fp: fingerprint(key, SUPP_SALT),
+ key_version: '1',
+ reason: 'the declaration row was misparsed; the stake was never declared',
+ corrected_at: '2026-08-11',
+ }) + '\n',
+ );
+ runLoad({ CACBG_CORRECTIONS_LIST: corrFile, SUPPRESSION_SALT: SUPP_SALT });
+
+ const snap = JSON.parse(fs.readFileSync(path.join(STAGING, 'published-snapshot.json'), 'utf8'));
+ const marked = snap.find((s) => s.link_key === key);
+ assert.ok(marked, 'the key must still be exported — the gate needs to SEE it leave');
+ assert.equal(marked.corrected, true);
+ // Every other key stays unflagged: an acknowledgement is per-link, never a blanket amnesty.
+ assert.equal(
+ snap.filter((s) => s.corrected === true).length,
+ 1,
+ 'one acknowledgement must not clear the whole surface',
+ );
+});
+
+test('a corrections entry matching NO previously published link fails the build', () => {
+ // The B3 rail, mirrored. A stale acknowledgement is worse than a missing one: it sits in the list
+ // and would clear a FUTURE disappearance of the same link — the exact regression the gate exists to
+ // catch — with nobody having decided that.
+ const corrFile = path.join(dir, 'corr-stale.jsonl');
+ fs.writeFileSync(
+ corrFile,
+ JSON.stringify({
+ fp: fingerprint('p-nobody|999999999', SUPP_SALT),
+ key_version: '1',
+ reason: 'stale',
+ corrected_at: '2026-08-11',
+ }) + '\n',
+ );
+ runLoad();
+ assert.throws(
+ () => runLoad({ CACBG_CORRECTIONS_LIST: corrFile, SUPPRESSION_SALT: SUPP_SALT }),
+ (err) =>
+ /correction/i.test(String(err.stderr ?? '') + String(err.message ?? '')) &&
+ /matched NO/i.test(String(err.stderr ?? '') + String(err.message ?? '')),
+ );
+});
+
+test('corrections are fail-closed on a missing salt, exactly like suppressions', () => {
+ const corrFile = path.join(dir, 'corr-nosalt.jsonl');
+ fs.writeFileSync(
+ corrFile,
+ JSON.stringify({ fp: 'deadbeef', key_version: '1', reason: 'x', corrected_at: '2026-08-11' }) +
+ '\n',
+ );
+ assert.throws(
+ () => runLoad({ CACBG_CORRECTIONS_LIST: corrFile, SUPPRESSION_SALT: '' }),
+ (err) => /SUPPRESSION_SALT is unset/.test(String(err.stderr ?? '') + String(err.message ?? '')),
+ );
+});
diff --git a/scripts/cacbg/parse.mjs b/scripts/cacbg/parse.mjs
index bf95f154..6e89e242 100644
--- a/scripts/cacbg/parse.mjs
+++ b/scripts/cacbg/parse.mjs
@@ -82,10 +82,18 @@ function cellsByNum(row) {
}
// find the @_Num of the first column whose @_Description matches `re` (labels live on the header row)
function colNum(firstRow, re, fallback) {
+ const found = colNumOrNull(firstRow, re);
+ return found ?? fallback;
+}
+// The same resolution WITHOUT the fallback, for the one column where "I could not find it" and "I found it
+// and it was blank" must stay distinguishable. `colNum` collapses them: an unresolvable column resolves to
+// a Num that is not in the row, `by[col]` is undefined, and the caller reads an empty cell it never found.
+// For the HOLDER column those two readings differ by who owns the stake (§1.4) — see its call site.
+function colNumOrNull(firstRow, re) {
for (const c of asArray(firstRow?.Cell)) {
if (c?.['@_Num'] && re.test(String(c?.['@_Description'] ?? ''))) return c['@_Num'];
}
- return fallback;
+ return null;
}
const year4 = (s) => String(s ?? '').match(/\b(20\d{2})\b/)?.[1] ?? null;
@@ -159,15 +167,20 @@ function parseAssets(pp) {
? colNum(rows[0], /наименование.*дружеств|фирма/i, '4')
: colNum(rows[0], /емитент/i, '6');
const cSeat = colNum(rows[0], /седалище/i, '5');
- const cHolder = colNum(rows[0], /собствено.*фамил/i, isOod ? '7' : '8');
+ // NO fallback here, unlike every other column (§1.4). A blank holder cell MEANS something — the stake is
+ // the declarant's own (classifyHolder) — so a column we failed to resolve must not imitate one. With a
+ // fallback, a renumbered table resolves the holder to a missing column, reads '' and calls a RELATIVE's
+ // stake the official's own, which then publishes as their private_ownership. Unresolvable ⇒ 'unknown',
+ // which forms no link at all: we did not read the holder, so we make no claim about them.
+ const cHolder = colNumOrNull(rows[0], /собствено.*фамил/i);
const cEgn = colNum(rows[0], /^егн$/i, isOod ? '8' : '9');
for (const row of rows) {
const by = cellsByNum(row);
const company = by[cCompany] ?? '';
if (!company) continue;
if ((by[cEgn] ?? '').length > 0) egnPresent = true;
- const holder = by[cHolder] ?? '';
- const holderRelation = classifyHolder(holder, declarant);
+ const holderRelation =
+ cHolder === null ? 'unknown' : classifyHolder(by[cHolder] ?? '', declarant);
if (holderRelation === 'related') familyHoldingCount += 1;
const seat = isOod ? (by[cSeat] ?? '') : '';
interests.push({
diff --git a/scripts/cacbg/parse.test.mjs b/scripts/cacbg/parse.test.mjs
index 30495481..6a9b835e 100644
--- a/scripts/cacbg/parse.test.mjs
+++ b/scripts/cacbg/parse.test.mjs
@@ -249,3 +249,35 @@ test('parseList: only values shaped like a declaration filename are announced',
assert.equal(shape('Уведомление'), 0, 'a title slotted into the filename field');
assert.equal(shape('AAAA.pdf'), 0, 'not a declaration document');
});
+
+test('§1.4: an UNRESOLVABLE holder column is unknown, not an own stake', () => {
+ // `colNum` always returns a fallback, so a table that renumbers the holder column away resolves it to a
+ // column that is not there: `by[cHolder]` is undefined → '' → classifyHolder('') → 'self'. A RELATIVE's
+ // stake then enters the OWN-only path and publishes as the official's private_ownership — the surface's
+ // worst failure, since the official did not declare that stake as theirs.
+ //
+ // The required distinction is column RESOLVABLE-BUT-EMPTY (→ self, a blank cell means the declarant)
+ // versus column NOT RESOLVABLE (→ unknown, we have not read the holder at all).
+ const renumbered = `
+ 1
+ "РЕНОМЕР" ЕООД
+ Мария Спасова Роднинска`;
+ const d = parseDeclaration(assetDecl({ rows: renumbered }), 'REN.xml');
+ const it = d.interests.find((i) => i.entity === '"РЕНОМЕР" ЕООД');
+ assert.equal(it.holderRelation, 'unknown');
+
+ // POSITIVE CONTROL 1 — the column resolves and the cell is genuinely EMPTY: still the declarant's own
+ // stake. Collapsing both cases to 'unknown' would silently drop every stake declared this way.
+ const blankHolder = `
+ 1
+ "ПРАЗНА" ЕООД
+ `;
+ const blank = parseDeclaration(assetDecl({ rows: blankHolder }), 'BLK.xml');
+ assert.equal(blank.interests[0].holderRelation, 'self');
+
+ // POSITIVE CONTROL 2 — a resolvable column still classifies a relative and the declarant correctly, so
+ // the change bounds one case rather than blanketing the parser.
+ const ok = parseDeclaration(assetDecl({ rows: selfRow + familyRow }), 'OK.xml');
+ assert.equal(ok.interests.find((i) => i.entity === '"ТЕСТ АГРО" ЕООД').holderRelation, 'self');
+ assert.equal(ok.interests.find((i) => i.entity === '"ФАМИЛНА" ЕООД').holderRelation, 'related');
+});
diff --git a/scripts/cacbg/suppressions.mjs b/scripts/cacbg/suppressions.mjs
index b1096c60..2c67c93d 100644
--- a/scripts/cacbg/suppressions.mjs
+++ b/scripts/cacbg/suppressions.mjs
@@ -33,6 +33,25 @@ export function fingerprint(linkKey, salt) {
* An absent or empty list needs no salt (nothing to fingerprint), so the common path stays friction-free.
*/
export function loadSuppressions(listPath, salt, keyVersion = SUPPRESSION_KEY_VERSION) {
+ return loadFingerprintedList(listPath, salt, keyVersion, 'suppression');
+}
+
+/**
+ * The MONOTONICITY-gate twin (ADR-0033 decision 6): links whose removal is licensed because their
+ * INPUT was wrong, not because the rules changed.
+ *
+ * A separate list from the suppressions, because the two are not the same act and cannot share one.
+ * A suppression keeps a built link OUT of the public surface; a correction says the link should never
+ * have been built at all — so correcting the input unbuilds it, and a suppression on it would then
+ * match nothing and trip the B3 unused gate. Same fingerprinting for the same reason: the raw
+ * `pid|eik` records which named official was tied to which company, which is precisely what must not
+ * live in git history. Consumed by load.mjs, which flags the matching keys in the pre-wipe snapshot.
+ */
+export function loadCorrections(listPath, salt, keyVersion = SUPPRESSION_KEY_VERSION) {
+ return loadFingerprintedList(listPath, salt, keyVersion, 'correction');
+}
+
+function loadFingerprintedList(listPath, salt, keyVersion, label) {
const entries = existsSync(listPath)
? readFileSync(listPath, 'utf8')
.split('\n')
@@ -42,14 +61,14 @@ export function loadSuppressions(listPath, salt, keyVersion = SUPPRESSION_KEY_VE
: [];
if (entries.length > 0 && !salt) {
throw new Error(
- `${entries.length} suppression(s) in ${listPath} but SUPPRESSION_SALT is unset — refusing to build ` +
+ `${entries.length} ${label}(s) in ${listPath} but SUPPRESSION_SALT is unset — refusing to build ` +
`(would silently un-suppress contested links). Set SUPPRESSION_SALT and retry.`,
);
}
for (const e of entries) {
if (String(e.key_version ?? '') !== String(keyVersion)) {
throw new Error(
- `suppression ${String(e.fp ?? '').slice(0, 12)}… has key_version ${JSON.stringify(e.key_version)} ` +
+ `${label} ${String(e.fp ?? '').slice(0, 12)}… has key_version ${JSON.stringify(e.key_version)} ` +
`but the current SUPPRESSION_KEY_VERSION is ${keyVersion} — refusing to build (a rotated salt ` +
`would silently un-suppress it). Re-fingerprint every entry under the current salt + key_version.`,
);
diff --git a/scripts/cacbg/tr-census.mjs b/scripts/cacbg/tr-census.mjs
deleted file mode 100644
index e7b8c66d..00000000
--- a/scripts/cacbg/tr-census.mjs
+++ /dev/null
@@ -1,195 +0,0 @@
-// TR name-uniqueness census (ADR-0015). Promotes tier-C held interest_links — generic company names
-// with a single WINNER namesake — to 'published' iff the name is GLOBALLY unique in the Trade Register.
-//
-// Source: the Commercial Register open-data dump on data.egov.bg (DPA-safe: ЕГН/ЛНЧ hashed out, company
-// name + ЕИК retained). We build companyNameKey(name) → {distinct ЕИК} over ALL active entities using
-// the SAME normalizer as the matcher, so the key spaces are identical. Promotion is deterministic:
-// key count == 1 AND that ЕИК == the matched winner ЕИК. Anything else stays held. No heuristic.
-//
-// Field-detection is structural (find the 9/13-digit ЕИК; find the company-name field), so it is robust
-// to the exact open-data column names — confirm the mapping against the real dump before a production run.
-//
-// Run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/tr-census.mjs --dump
-import { DatabaseSync } from 'node:sqlite';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
-const DB = process.env.CACBG_DB || path.join(ROOT, 'data/work/backfill.sqlite');
-const arg = (n) => {
- const i = process.argv.indexOf(`--${n}`);
- return i >= 0 ? process.argv[i + 1] : undefined;
-};
-const DUMP = arg('dump') || process.env.TR_DUMP;
-const { companyNameKey } = await import('../../packages/shared/src/company-name-key.ts');
-
-const isEik = (v) => typeof v === 'string' && /^\d{9}$|^\d{13}$/.test(v.trim());
-
-// The register stores CompanyName WITHOUT the legal form and LegalForm as a code; our matcher keys on the
-// name WITH its Bulgarian form (bidders.name = «…» ЕООД), so we must reconstruct it or every lookup misses.
-// Codes seen in the real dump: EOOD OOD ASSOC CC K EAD ET AD KCHT FOUND KD SD DPK EDPK IAD IEAD.
-const LEGAL_FORM = {
- EOOD: 'ЕООД',
- OOD: 'ООД',
- EAD: 'ЕАД',
- AD: 'АД',
- ET: 'ЕТ',
- KD: 'КД',
- K: 'КД',
- SD: 'СД',
- KCHT: 'КДА',
- IAD: 'ИАД',
- IEAD: 'ИЕАД',
- DPK: 'ПК',
- EDPK: 'ЕПК',
- CC: 'кооперация',
- ASSOC: 'СНЦ',
- FOUND: 'фондация',
-};
-// A Bulgarian trade name reconstructed to match the matcher's key space. Unknown form → append the raw
-// code (harmless: it just forms its own key that no bidder lookup will hit).
-const bgName = (companyName, legalForm) =>
- `${String(companyName ?? '').trim()} ${LEGAL_FORM[legalForm] ?? String(legalForm ?? '').trim()}`.trim();
-
-// Extract {eik, name} from ONE TR deed's attribute object ({CompanyName, LegalForm, UIC, DeedStatus, …}).
-// Also tolerates a flat {eik/uic, name} record (test fixtures). ЕИК = the 9/13-digit UIC.
-export function extractEntity(rec) {
- const uic = rec.UIC ?? rec.uic ?? rec.eik ?? rec.EIK;
- const eik = isEik(String(uic ?? '')) ? String(uic).trim() : null;
- const name =
- rec.CompanyName != null ? bgName(rec.CompanyName, rec.LegalForm) : (rec.name ?? null);
- return { eik, name: name || null };
-}
-
-// Read a TR dump as deed-attribute records. The real data.egov.bg export nests them at
-// Message[].Body[].Deeds[].Deed[], each carrying its fields under `$` (fast-xml/attr style). Falls back to
-// a plain JSON array / {data:[…]} / JSONL of already-flat records so tests and other shapes still work.
-function* records(dump) {
- const raw = fs.readFileSync(dump, 'utf8');
- const trimmed = raw.trimStart();
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
- const parsed = JSON.parse(raw);
- const deeds = parsed?.Message?.[0]?.Body?.[0]?.Deeds?.[0]?.Deed;
- if (Array.isArray(deeds)) {
- for (const d of deeds) yield d?.['$'] ?? d;
- return;
- }
- const arr = Array.isArray(parsed)
- ? parsed
- : (parsed.data ?? parsed.records ?? parsed.result ?? []);
- yield* arr;
- } else {
- for (const line of raw.split('\n')) {
- const t = line.trim();
- if (t) yield JSON.parse(t);
- }
- }
-}
-
-// Build companyNameKey → Set(ЕИК) over one or more dump files (accumulates — call with every daily file to
-// approach the full register). Deduped by ЕИК within a key, so the same company appearing on many days
-// (or a name→one company) counts once; two DISTINCT companies sharing a name yield size>1 = not unique.
-export function buildCensus(dumps) {
- const census = new Map();
- for (const dump of Array.isArray(dumps) ? dumps : [dumps]) {
- for (const rec of records(dump)) {
- const { eik, name } = extractEntity(rec);
- if (!eik || !name) continue;
- const key = companyNameKey(name);
- if (!census.has(key)) census.set(key, new Set());
- census.get(key).add(eik);
- }
- }
- return census;
-}
-
-// Promote held tier-C links the census proves globally unique: name-key maps to EXACTLY one ЕИК AND that
-// ЕИК is the matched winner's (a national namesake, even at a different ЕИК, keeps the link held). With
-// dryRun, report the would-promote set without writing — used to validate against a PARTIAL census, which
-// must never actually promote (an un-ingested namesake would make a false-unique claim = libel).
-export function promote(db, census, { dryRun = false, minEik = null, forcePartial = false } = {}) {
- // Partial-census guard (libel gate). A census smaller than the real ТР register can make a genuinely
- // non-unique name look unique (`eiks.size === 1`) → a false, libelous attribution. A real promote must
- // ASSERT coverage in code, not by convention: pass `--min-eik ≥ register size` (the census's distinct-ЕИК
- // count must meet it) or the explicit `--force-partial` escape hatch. dryRun is exempt — it writes nothing
- // and exists precisely to inspect a partial census.
- if (!dryRun && !forcePartial) {
- if (!Number.isInteger(minEik) || minEik < 1) {
- throw new Error(
- 'refusing to promote without --min-eik : a partial census silently fabricates ' +
- 'false-unique attributions. Pass --min-eik ≥ the known ТР register size, or --force-partial to override.',
- );
- }
- const distinctEik = new Set();
- for (const s of census.values()) for (const e of s) distinctEik.add(e);
- if (distinctEik.size < minEik) {
- throw new Error(
- `refusing to promote from a partial census: ${distinctEik.size} distinct ЕИК < --min-eik ${minEik}. ` +
- 'Ingest more ТР dumps until coverage meets the register size, or pass --force-partial to override.',
- );
- }
- }
- const held = db
- .prepare(
- "SELECT link_key, eik, entity_key FROM interest_links WHERE status='held' AND publish_tier='C_hold'",
- )
- .all();
- const upd = db.prepare(
- "UPDATE interest_links SET status='published', match_method='exact_name_key+tr_census' WHERE link_key=?",
- );
- const would = [];
- if (!dryRun) db.exec('BEGIN');
- for (const l of held) {
- const eiks = census.get(l.entity_key);
- if (eiks && eiks.size === 1 && eiks.has(l.eik)) {
- would.push(l.link_key);
- if (!dryRun) upd.run(l.link_key);
- }
- }
- if (!dryRun) db.exec('COMMIT');
- return { promoted: would.length, stillHeld: held.length - would.length, would };
-}
-
-// Resolve --dump: a single file, a comma-list, or --dump-dir (every *.json/*.jsonl inside).
-function dumpFiles() {
- const dir = arg('dump-dir');
- if (dir)
- return fs
- .readdirSync(dir)
- .filter((f) => /\.(json|jsonl)$/.test(f))
- .map((f) => path.join(dir, f));
- return DUMP
- ? DUMP.split(',')
- .map((s) => s.trim())
- .filter(Boolean)
- : [];
-}
-
-// CLI entry (guarded so importing this module in tests has no side effects)
-const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
-if (isMain) {
- const files = dumpFiles();
- const dryRun = process.argv.includes('--dry-run');
- const forcePartial = process.argv.includes('--force-partial');
- const minEik = arg('min-eik') !== undefined ? Number(arg('min-eik')) : null;
- if (!files.length) {
- console.log(
- 'no --dump/--dump-dir provided; census not run. Provide TR open-data files to promote tier-C links.',
- );
- } else {
- const db = new DatabaseSync(DB);
- const census = buildCensus(files);
- const eikTotal = [...census.values()].reduce((n, s) => n + s.size, 0);
- console.log(
- `census: ${files.length} file(s) → ${census.size} distinct name-keys / ${eikTotal} ЕИК`,
- );
- const { promoted, stillHeld, would } = promote(db, census, { dryRun, minEik, forcePartial });
- console.log(
- `${dryRun ? 'DRY-RUN would promote' : 'tier-C promotions'}: ${promoted} published, ${stillHeld} still held (non-unique or namesake mismatch)`,
- );
- if (dryRun && would.length)
- console.log(' would-promote link_keys:\n ' + would.join('\n '));
- db.close();
- }
-}
diff --git a/scripts/cacbg/tr-census.test.mjs b/scripts/cacbg/tr-census.test.mjs
deleted file mode 100644
index 76d2fbb0..00000000
--- a/scripts/cacbg/tr-census.test.mjs
+++ /dev/null
@@ -1,121 +0,0 @@
-// TR census — real data.egov.bg deed shape + deterministic tier-C promotion.
-// Run: node --import ./scripts/cacbg/register-ts.mjs --test scripts/cacbg/tr-census.test.mjs
-import { test, before, after } from 'node:test';
-import assert from 'node:assert/strict';
-import { DatabaseSync } from 'node:sqlite';
-import fs from 'node:fs';
-import path from 'node:path';
-import os from 'node:os';
-import { extractEntity, buildCensus, promote } from './tr-census.mjs';
-const { companyNameKey } = await import('../../packages/shared/src/company-name-key.ts');
-
-let dir, dump, dbPath;
-const K = (s) => companyNameKey(s);
-
-before(() => {
- dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-census-'));
- dump = path.join(dir, 'tr.json');
- // Real export shape: Message[].Body[].Deeds[].Deed[], each with its fields under `$`; CompanyName is
- // bare and LegalForm is a code — the census must reconstruct «name + Bulgarian form» to match bidders.
- const deeds = [
- { CompanyName: 'СИЙ', LegalForm: 'AD', UIC: '444444447' }, // globally unique → promote
- { CompanyName: 'ОБЩА ФИРМА', LegalForm: 'OOD', UIC: '555555556' }, // one of two namesakes
- { CompanyName: 'Обща Фирма', LegalForm: 'OOD', UIC: '666666663' }, // second namesake → NOT unique
- { CompanyName: 'СИЙ', LegalForm: 'AD', UIC: '444444447' }, // same deed on another day → deduped
- ];
- fs.writeFileSync(
- dump,
- JSON.stringify({ Message: [{ Body: [{ Deeds: [{ Deed: deeds.map((d) => ({ $: d })) }] }] }] }),
- );
-
- dbPath = path.join(dir, 'db.sqlite');
- const db = new DatabaseSync(dbPath);
- db.exec(`
- CREATE TABLE interest_links(link_key TEXT PRIMARY KEY, eik TEXT, entity_key TEXT, status TEXT, publish_tier TEXT, match_method TEXT);
- INSERT INTO interest_links VALUES ('p1|444444447','444444447','${K('СИЙ АД')}','held','C_hold','exact_name_key');
- INSERT INTO interest_links VALUES ('p2|555555556','555555556','${K('ОБЩА ФИРМА ООД')}','held','C_hold','exact_name_key');
- `);
- db.close();
-});
-
-after(() => fs.rmSync(dir, { recursive: true, force: true }));
-
-test('extractEntity reconstructs «name + Bulgarian legal form» from a TR deed and reads UIC as ЕИК', () => {
- assert.deepEqual(extractEntity({ CompanyName: 'СИЙ', LegalForm: 'AD', UIC: '444444447' }), {
- eik: '444444447',
- name: 'СИЙ АД',
- });
- assert.deepEqual(extractEntity({ CompanyName: 'ТЕСТ', LegalForm: 'EOOD', UIC: '205057459' }), {
- eik: '205057459',
- name: 'ТЕСТ ЕООД',
- });
- assert.equal(extractEntity({ CompanyName: 'X', LegalForm: 'AD', UIC: '2018060520' }).eik, null); // 10 digits ≠ ЕИК
- assert.equal(extractEntity({ name: 'flat ООД', uic: '5555555560000' }).eik, '5555555560000'); // flat fallback + 13-digit
-});
-
-test('buildCensus indexes name-key → set of ЕИК over the nested export (dedup by ЕИК)', () => {
- const c = buildCensus(dump);
- assert.equal(c.get(K('СИЙ АД')).size, 1); // appears twice, one ЕИК → deduped
- assert.equal(c.get(K('ОБЩА ФИРМА ООД')).size, 2); // two distinct ЕИК fold to one key → non-unique
-});
-
-test('promote publishes only globally-unique tier-C links; shared names stay held', () => {
- const db = new DatabaseSync(dbPath);
- // census covers 3 distinct ЕИК → assert coverage with --min-eik 3 (the partial-census gate).
- const res = promote(db, buildCensus(dump), { minEik: 3 });
- assert.equal(res.promoted, 1);
- const rows = new Map(
- db
- .prepare('SELECT link_key,status,match_method FROM interest_links')
- .all()
- .map((r) => [r.link_key, r]),
- );
- assert.equal(rows.get('p1|444444447').status, 'published');
- assert.match(rows.get('p1|444444447').match_method, /tr_census/);
- assert.equal(rows.get('p2|555555556').status, 'held'); // two namesakes → stays held
- db.close();
-});
-
-test('partial-census gate: a real promote refuses without --min-eik, and when coverage is below it', () => {
- const db = new DatabaseSync(dbPath);
- // isolate: p1 back to held (an earlier test may have published it — shared dbPath)
- db.exec(
- "UPDATE interest_links SET status='held', match_method='exact_name_key' WHERE link_key='p1|444444447'",
- );
- const census = buildCensus(dump); // 3 distinct ЕИК
- // no --min-eik → refuse (can't assert completeness → could fabricate a false-unique attribution)
- assert.throws(() => promote(db, census, {}), /--min-eik/);
- // --min-eik above the census's coverage → refuse (partial dump)
- assert.throws(() => promote(db, census, { minEik: 4 }), /partial census|distinct ЕИК/);
- // the refused runs published nothing — p1 is still held
- assert.equal(
- db.prepare("SELECT status FROM interest_links WHERE link_key='p1|444444447'").get().status,
- 'held',
- );
- db.close();
-});
-
-test('partial-census gate: --force-partial is the explicit override', () => {
- const db = new DatabaseSync(dbPath);
- db.exec(
- "UPDATE interest_links SET status='held', match_method='exact_name_key' WHERE link_key='p1|444444447'",
- );
- const res = promote(db, buildCensus(dump), { forcePartial: true }); // no minEik, but forced
- assert.equal(res.promoted, 1);
- db.close();
-});
-
-test('dry-run reports would-promote set without mutating the DB', () => {
- const db = new DatabaseSync(dbPath);
- // reset p1 back to held for an isolated dry-run check
- db.exec(
- "UPDATE interest_links SET status='held', match_method='exact_name_key' WHERE link_key='p1|444444447'",
- );
- const res = promote(db, buildCensus(dump), { dryRun: true });
- assert.deepEqual(res.would, ['p1|444444447']);
- assert.equal(
- db.prepare("SELECT status FROM interest_links WHERE link_key='p1|444444447'").get().status,
- 'held',
- ); // untouched
- db.close();
-});
diff --git a/scripts/cli-ts-imports.test.mjs b/scripts/cli-ts-imports.test.mjs
new file mode 100644
index 00000000..3b59e5f1
--- /dev/null
+++ b/scripts/cli-ts-imports.test.mjs
@@ -0,0 +1,69 @@
+// The plain-Node CLI scripts import shared logic straight out of packages/ as raw TypeScript, which
+// Node type-strips on the fly. That resolver is far stricter than the bundler one the vitest suites
+// run under: vite happily resolves an extensionless relative specifier, plain Node does not. So an
+// import added to a shared module can be green across every package suite and still make
+// `node scripts/load-fx.mjs` die at startup with ERR_MODULE_NOT_FOUND — which is exactly what
+// happened while writing the drain fix in this branch, and nothing in CI noticed.
+//
+// This test closes that gap the only way that proves anything: it resolves each specifier the way
+// Node's ESM loader does (relative to the importing script) and imports it in a child process with
+// no hooks, no register shim and no bundler, asserting the whole graph loads.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { readdirSync, readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+// Every `import … from ''` in a CLI script that reaches outside scripts/ — any extension or
+// none, since an extensionless one is precisely the shape that breaks. Only the top-level scripts:
+// scripts/cacbg/* run under the register-ts hook and are a different contract.
+const IMPORT_RE = /\bfrom\s+'(\.\.\/[^']+)'/g;
+
+function crossPackageImports() {
+ const found = [];
+ for (const name of readdirSync(here).sort()) {
+ if (!name.endsWith('.mjs') || name.endsWith('.test.mjs')) continue;
+ const file = resolve(here, name);
+ const src = readFileSync(file, 'utf8');
+ for (const [, specifier] of src.matchAll(IMPORT_RE)) found.push({ name, file, specifier });
+ }
+ return found;
+}
+
+const imports = crossPackageImports();
+
+test('the scan actually finds the cross-package CLI imports', () => {
+ // Without this the whole file degrades to a no-op the moment the regex or the layout drifts —
+ // zero pairs would mean zero assertions and a green run. The three known importers today are
+ // import.mjs, load-eop.mjs and load-fx.mjs.
+ assert.ok(imports.length >= 3, `expected cross-package imports, found ${imports.length}`);
+ const importers = new Set(imports.map((i) => i.name));
+ for (const expected of ['import.mjs', 'load-eop.mjs', 'load-fx.mjs']) {
+ assert.ok(importers.has(expected), `${expected} should import shared package code`);
+ }
+});
+
+for (const { name, file, specifier } of imports) {
+ test(`${name} → ${specifier} loads under plain node`, () => {
+ // ESM relative resolution IS new URL(specifier, parent) — no extension search, no index lookup.
+ // Doing it here reproduces the loader's answer for the specifier exactly as the script wrote it.
+ const target = new URL(specifier, pathToFileURL(file)).href;
+ try {
+ execFileSync(
+ 'node',
+ ['--input-type=module', '-e', `await import(${JSON.stringify(target)})`],
+ {
+ encoding: 'utf8',
+ stdio: 'pipe',
+ },
+ );
+ } catch (e) {
+ assert.fail(
+ `${name} cannot load ${specifier} under plain node — the CLI would die at startup:\n${e.stderr}`,
+ );
+ }
+ });
+}
diff --git a/scripts/derive-amendments.sql b/scripts/derive-amendments.sql
index 2ab13245..217490ea 100644
--- a/scripts/derive-amendments.sql
+++ b/scripts/derive-amendments.sql
@@ -1,16 +1,134 @@
-- Sigma — roll raw_amendments up onto raw_contracts.
-- Run AFTER scripts/load-eop.mjs (which stages the EOP base + in-bucket OCDS amendments).
--- Re-runnable: resets the rollup, then matches amendments by (unp, contract_number).
+-- Re-runnable. On the full-derive path scripts/resolve-amendment-contracts.sql (#306) has already rewritten
+-- namespace-mismatched EOP annexes onto their target contract_number BEFORE this file runs. This file then
+-- recovers the УНП for OCDS amendments via the tender.id bridge (#286), prefers the EOP annex over its OCDS
+-- twin (dropping the twin), and matches amendments by (unp, contract_number).
-- current_value = the after-value of the LATEST amendment; annex_count = how many.
-- Contracts without amendments keep annex_count = 0 and current_value = NULL (the
-- convention downstream is COALESCE(current_value, signing_value)).
+-- #286: OCDS amendments stage the OCID in `unp` (the УНП is absent from the OCDS release), so they
+-- match no contract. Recover the real УНП through the same bridge the OCDS-lots enrichment uses
+-- (normalize-raw.sql): OCDS tender.id (staged as raw_amendments.tender_ext_id) → EOP tenderId → УНП.
+-- The EOP tenderId lives in raw_tenders.tender_id for procedures in the поръчки feed AND in
+-- raw_contracts.tender_ext_id for procedures that appear only as contracts (the "synthetic tenders"
+-- of normalize-raw §2b) — so try raw_tenders first, then fall back to raw_contracts, else leave the
+-- ocid untouched. The ocid stays only as a surrogate. Idempotent: a full run re-stages raw_amendments
+-- and recomputes the same УНП. Being the first amendment step in the full pipeline, it leaves
+-- raw_amendments corrected for promote-amendments.sql.
+-- KEEP THE BRIDGE UPDATE BELOW IN LOCKSTEP with scripts/refresh-slice.sql: the UPDATE between the
+-- @bridge-lockstep markers must stay byte-for-byte equivalent across both scripts (enforced by
+-- packages/db/src/amendments-bridge-lockstep.test.ts). The prefer-EOP dedup DELETE deliberately does NOT
+-- match — the slice path additionally reconciles against the cumulative served `amendments`, which this
+-- full path never needs (promote-amendments.sql rebuilds it from scratch). raw_tenders(tender_id) is
+-- indexed in work-staging-schema.sql, but raw_contracts(tender_ext_id) is not, so the fallback lookups
+-- below would full-scan raw_contracts once per OCDS row — index it defensively (mirrors the lots bridge,
+-- which indexes raw_tenders.tender_id before its join in normalize-raw.sql). The WHERE bridges only when
+-- the chosen source maps the tender_ext_id to exactly ONE distinct УНП — see the refuse-on-ambiguity note
+-- inside the block; the ocds_ambiguous_bridges diagnostic below surfaces any refusal.
+CREATE INDEX IF NOT EXISTS idx_raw_contracts_tender_ext_id ON raw_contracts(tender_ext_id);
+-- @bridge-lockstep start
+UPDATE raw_amendments
+SET unp = COALESCE(
+ (SELECT rt.unp FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL ORDER BY rt.unp LIMIT 1),
+ (SELECT rc.unp FROM raw_contracts rc
+ WHERE rc.tender_ext_id = raw_amendments.tender_ext_id AND rc.unp IS NOT NULL ORDER BY rc.unp LIMIT 1)
+)
+WHERE source LIKE 'ocds:%'
+ AND tender_ext_id IS NOT NULL
+ AND (
+ -- raw_tenders wins when it resolves the procedure to exactly ONE УНП; else fall back to raw_contracts
+ -- when IT is unambiguous. Refuse to bridge (leave the OCID as an honest residual) when the chosen
+ -- source maps one tender_ext_id to more than one distinct УНП — the domain is 1-to-1, so this guards a
+ -- feed anomaly rather than silently mis-attributing every annex of the losing procedure (issue #286).
+ (SELECT COUNT(DISTINCT rt.unp) FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL) = 1
+ OR (
+ NOT EXISTS (SELECT 1 FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL)
+ AND (SELECT COUNT(DISTINCT rc.unp) FROM raw_contracts rc
+ WHERE rc.tender_ext_id = raw_amendments.tender_ext_id AND rc.unp IS NOT NULL) = 1
+ )
+ );
+-- @bridge-lockstep end
+
+-- #286 diagnostic (printed by wrangler, review nikimilenkov LOW 1): count OCDS amendments the bridge
+-- REFUSED because their tender_ext_id resolves to more than one distinct УНП in the chosen source. The
+-- domain is 1-to-1, so this must be 0 on a healthy feed; a non-zero value is a feed anomaly to investigate,
+-- not a silent mis-attribution. Runs after the bridge, so refused rows still carry their OCID.
+SELECT COUNT(*) AS ocds_ambiguous_bridges
+FROM raw_amendments o
+WHERE o.source LIKE 'ocds:%'
+ AND o.tender_ext_id IS NOT NULL
+ AND o.unp LIKE 'ocds-%'
+ AND (
+ (SELECT COUNT(DISTINCT rt.unp) FROM raw_tenders rt
+ WHERE rt.tender_id = o.tender_ext_id AND rt.unp IS NOT NULL) > 1
+ OR (
+ NOT EXISTS (SELECT 1 FROM raw_tenders rt
+ WHERE rt.tender_id = o.tender_ext_id AND rt.unp IS NOT NULL)
+ AND (SELECT COUNT(DISTINCT rc.unp) FROM raw_contracts rc
+ WHERE rc.tender_ext_id = o.tender_ext_id AND rc.unp IS NOT NULL) > 1
+ )
+ );
+
+-- #286 diagnostic (printed by wrangler, BEFORE the drop below): keep the residual OCDS under-count
+-- OBSERVABLE. The prefer-EOP dedup is contract-level — it drops EVERY OCDS annex on a contract that
+-- already has an EOP annex, so a genuinely OCDS-only *extra* amendment there is lost. Per-annex twin
+-- matching can't rescue it: OCDS document_number is the release id (ocds-…) while EOP's is the АОП
+-- document number — different id spaces that never align, so we cannot prove per-annex which drops are
+-- true twins. Report bounds instead (run after the bridge above, so o.unp is the recovered УНП):
+-- dropped = every OCDS annex removed by the dedup (UPPER bound on annexes lost)
+-- excess_over_eop = Σ max(0, ocds_on_contract − eop_on_contract) (LOWER bound: cannot all be twins)
+SELECT
+ COALESCE(SUM(g.ocds_n), 0) AS ocds_annexes_dropped,
+ COALESCE(SUM(CASE WHEN g.ocds_n > g.eop_n THEN g.ocds_n - g.eop_n ELSE 0 END), 0)
+ AS ocds_annexes_excess_over_eop
+FROM (
+ SELECT o.unp, o.contract_number,
+ COUNT(*) AS ocds_n,
+ (SELECT COUNT(*) FROM raw_amendments e
+ WHERE e.source LIKE 'eop:%' AND e.unp = o.unp AND e.contract_number = o.contract_number) AS eop_n
+ FROM raw_amendments o
+ WHERE o.source LIKE 'ocds:%'
+ AND EXISTS (SELECT 1 FROM raw_amendments e
+ WHERE e.source LIKE 'eop:%' AND e.unp = o.unp AND e.contract_number = o.contract_number)
+ GROUP BY o.unp, o.contract_number
+) g;
+
+-- #286: prefer the EOP annex. ~99% of OCDS amendments duplicate an EOP annex for the same contract;
+-- keeping both would double annex_count and duplicate the served timeline. Drop the OCDS twin when an
+-- EOP annex already exists for the same (unp, contract_number). Genuinely OCDS-only annexes survive
+-- (they carry value_after = NULL from ingest, so they never drive current_value — issue #286).
+DELETE FROM raw_amendments
+WHERE source LIKE 'ocds:%'
+ AND EXISTS (
+ SELECT 1 FROM raw_amendments e
+ WHERE e.source LIKE 'eop:%'
+ AND e.unp = raw_amendments.unp
+ AND e.contract_number = raw_amendments.contract_number
+ );
+
+-- #306: the value-anchor resolver that links namespace-mismatched EOP annexes (annex-side number ≠ the
+-- contract's filing number) by rewriting raw_amendments.contract_number lives in
+-- scripts/resolve-amendment-contracts.sql and runs BEFORE this file on the full-derive path only (see that
+-- file's header for the ordering-blocker and slice-gating rationale — reviews todorkolev #1/#3, nikimilenkov
+-- HIGH 1). By the time this rollup runs, resolved annexes already carry their target contract_number (and
+-- keep the original annex number in contract_number_raw for the natural_key below), so the rollup links them
+-- with no further change.
+
UPDATE raw_contracts SET annex_count = 0, current_value = NULL;
WITH keyed AS (
SELECT
*,
- 'am:' || COALESCE(unp, '') || ':' || COALESCE(contract_number, '') || ':' ||
+ -- #306: key on the ORIGINAL annex-side number (contract_number_raw) when the value resolver rewrote a
+ -- row, so a resolved annex keeps a stable identity and never collides with a native annex that shares
+ -- document_number on the target contract (review nikimilenkov MEDIUM 3). NULL raw → the plain number,
+ -- so unresolved rows and the slice path (which never resolves) produce byte-identical keys.
+ 'am:' || COALESCE(unp, '') || ':' || COALESCE(NULLIF(contract_number_raw, ''), contract_number, '') || ':' ||
COALESCE(
NULLIF(document_number, ''),
NULLIF(correction_number, ''),
@@ -39,8 +157,10 @@ SET
AND a.contract_number = raw_contracts.contract_number
AND a.rn = 1
),
+ -- #305 Tier-2: a text-confirmed double-count carries the corrected total in value_after_restated; use
+ -- it as the effective after so current_value reflects the true total, not the raw doubled value_after.
current_value = (
- SELECT a.value_after FROM dedup a
+ SELECT COALESCE(a.value_after_restated, a.value_after) FROM dedup a
WHERE a.unp = raw_contracts.unp
AND a.contract_number = raw_contracts.contract_number
AND a.value_after IS NOT NULL
diff --git a/scripts/import-catchup.test.mjs b/scripts/import-catchup.test.mjs
new file mode 100644
index 00000000..dc434472
--- /dev/null
+++ b/scripts/import-catchup.test.mjs
@@ -0,0 +1,117 @@
+// safeD1's missing-table detection, exercised through the real scripts/import.mjs.
+//
+// safeD1 has to tell "this table does not exist yet" (recoverable — latestLoadedDate falls back to
+// data_freshness) apart from every other failure (must propagate). Getting that wrong breaks
+// `import --catchup --plan-only` in its NORMAL state: drop-transient-staging removes raw_contracts in
+// a finally, and --plan-only exits before the main flow recreates it. #277 fixed it; nothing tested it,
+// because import.mjs runs on import and cannot be imported. The subprocess harness from #270 can.
+//
+// Run: node --test scripts/import-catchup.test.mjs
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(HERE, '..');
+const SCRIPT = resolve(HERE, 'import.mjs');
+
+// VERIFIED AGAINST THE REAL BINARY, not imagined — the whole point of this test is the stream layout,
+// so modelling it from memory would test nothing. Observed from
+// `wrangler d1 execute sigma --local --json --command "SELECT 1 FROM no_such_table_xyz"`:
+//
+// exit code : 1
+// stdout : {"error":{"text":"no such table: no_such_table_xyz: SQLITE_ERROR"}}
+// stderr : ▲ [WARNING] Processing wrangler.jsonc configuration: ...
+// err.message from execFileSync : "Command failed: wrangler d1 execute ..."
+//
+// So the SQLite error is on STDOUT while wrangler's notices are on STDERR, and the exception message
+// carries neither. That asymmetry is exactly what the old `err.message`-only check missed.
+const SQLITE_ERROR_ON_STDOUT = '{"error":{"text":"no such table: raw_contracts: SQLITE_ERROR"}}';
+
+const FAKE_WRANGLER = `#!${process.execPath}
+import { appendFileSync } from 'node:fs';
+const argv = process.argv.slice(2);
+appendFileSync(process.env.CU_LOG, JSON.stringify(argv) + '\\n');
+const ci = argv.indexOf('--command');
+if (ci !== -1) {
+ const sql = argv[ci + 1];
+ if (/raw_contracts/.test(sql)) {
+ if (process.env.CU_OTHER_ERROR) {
+ process.stdout.write(JSON.stringify({ error: { text: 'database is locked: SQLITE_BUSY' } }));
+ } else {
+ process.stdout.write(${JSON.stringify(SQLITE_ERROR_ON_STDOUT)});
+ }
+ process.stderr.write('\\u25b2 [WARNING] Processing wrangler.jsonc configuration:\\n');
+ process.exit(1);
+ }
+ if (/data_freshness/.test(sql)) {
+ process.stdout.write(JSON.stringify([{ results: [{ max_loaded_date: '2026-07-27' }], success: true }]));
+ process.exit(0);
+ }
+ process.stdout.write(JSON.stringify([{ results: [], success: true }]));
+}
+process.exit(0);
+`;
+
+// Stands in for the child scripts import.mjs shells out to, so a run that gets past planning stops
+// here rather than reaching the network. The parent runs under process.execPath, so it keeps the real
+// node while the child's PATH lookup finds this.
+const FAKE_NODE = `#!${process.execPath}
+process.exit(0);
+`;
+
+function runImport(args, env = {}) {
+ const dir = mkdtempSync(join(tmpdir(), 'catchup-'));
+ try {
+ const binDir = join(dir, 'bin');
+ mkdirSync(binDir);
+ writeFileSync(join(binDir, 'package.json'), '{"type":"module"}');
+ for (const [name, src] of [
+ ['wrangler', FAKE_WRANGLER],
+ ['node', FAKE_NODE],
+ ]) {
+ writeFileSync(join(binDir, name), src);
+ chmodSync(join(binDir, name), 0o755);
+ }
+ const log = join(dir, 'calls.log');
+ writeFileSync(log, '');
+ const res = spawnSync(process.execPath, [SCRIPT, ...args], {
+ cwd: ROOT,
+ encoding: 'utf8',
+ timeout: 30_000,
+ env: { ...process.env, PATH: `${binDir}:${process.env.PATH}`, CU_LOG: log, ...env },
+ });
+ const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+ return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '', calls };
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+test('a missing raw_contracts falls back to data_freshness instead of crashing', () => {
+ // The reported failure: --plan-only in its normal steady state, with the transient staging gone.
+ const r = runImport(['--catchup', '--plan-only']);
+ assert.equal(r.status, 0, `--plan-only should survive a missing raw_contracts:\n${r.stderr}`);
+ assert.match(r.stdout, /catchup plan/);
+ // The date can only have come from the data_freshness fallback — raw_contracts never answered.
+ assert.match(r.stdout, /maxLoadedDate=2026-07-27/);
+});
+
+test('the fallback query is actually reached, not skipped', () => {
+ // Guards against a future "fix" that returns a plan without asking data_freshness at all, which
+ // would pass the assertion above by accident.
+ const r = runImport(['--catchup', '--plan-only']);
+ const asked = r.calls.some((c) => /data_freshness/.test(String(c[c.length - 1])));
+ assert.ok(asked, 'safeD1 swallowed the error but nothing consulted data_freshness');
+});
+
+test('an error that is NOT a missing table still propagates', () => {
+ // safeD1 must not become a blanket catch. A locked database is not "no corpus yet", and treating it
+ // as one would let the catch-up plan be computed from a lie.
+ const r = runImport(['--catchup', '--plan-only'], { CU_OTHER_ERROR: '1' });
+ assert.notEqual(r.status, 0, 'a SQLITE_BUSY must not be swallowed as an empty result');
+});
diff --git a/scripts/import-guard.test.mjs b/scripts/import-guard.test.mjs
new file mode 100644
index 00000000..f82c8b8c
--- /dev/null
+++ b/scripts/import-guard.test.mjs
@@ -0,0 +1,201 @@
+// The --derive=full window guard, exercised through the real scripts/import.mjs.
+//
+// Testing the predicate alone is what let the first version ship: fullDeriveIsSafe() was green while
+// the call site asked `SELECT COUNT(*) FROM contracts` and the clear it was protecting emptied
+// fourteen tables. So this drives the actual script as a subprocess, with a fake `wrangler` and a
+// fake `node` first on PATH, and asserts on what the script DID: whether it refused, which tables it
+// named, whether it cleaned up after itself, and whether the load ever started.
+//
+// Run: node --test scripts/import-guard.test.mjs
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(HERE, '..');
+const SCRIPT = resolve(HERE, 'import.mjs');
+
+// `wrangler` answers the probe from GUARD_FAKE_POPULATED and records every call; `node` stands in for
+// the child scripts import.mjs shells out to (load-eop and friends), so a run that gets PAST the guard
+// stops here instead of hitting the network. Launching the script under test with process.execPath
+// keeps the real node for the parent while the child's PATH lookup finds the stub.
+// The shebangs name the real interpreter outright. `#!/usr/bin/env node` would resolve through the
+// very PATH these stubs sit at the front of, so the fake `node` would end up interpreting the fake
+// `wrangler` and every answer would come back empty.
+const FAKE_WRANGLER = `#!${process.execPath}
+import { appendFileSync } from 'node:fs';
+const argv = process.argv.slice(2);
+appendFileSync(process.env.GUARD_FAKE_LOG, JSON.stringify(argv) + '\\n');
+const ci = argv.indexOf('--command');
+if (argv.includes('--json') && ci !== -1) {
+ const sql = argv[ci + 1];
+ const populated = (process.env.GUARD_FAKE_POPULATED || '').split(',').filter(Boolean);
+ const missing = (process.env.GUARD_FAKE_MISSING || '').split(',').filter(Boolean);
+ const aliases = [...sql.matchAll(/AS "([^"]+)"/g)].map((m) => m[1]);
+ if (aliases.length) {
+ const row = {};
+ for (const a of aliases) if (!missing.includes(a)) row[a] = populated.includes(a) ? 1 : 0;
+ process.stdout.write(JSON.stringify([{ results: [row], success: true }]));
+ process.exit(0);
+ }
+ process.stdout.write(JSON.stringify([{ results: [], success: true }]));
+}
+process.exit(0);
+`;
+
+const FAKE_NODE = `#!${process.execPath}
+import { appendFileSync } from 'node:fs';
+appendFileSync(process.env.GUARD_FAKE_LOG, JSON.stringify(['node', ...process.argv.slice(2)]) + '\\n');
+process.exit(0);
+`;
+
+function bin(dir, name, source) {
+ const file = join(dir, name);
+ writeFileSync(file, source);
+ chmodSync(file, 0o755);
+}
+
+/** Runs the real import.mjs with the fakes in front, and returns what it did. */
+function runImport(args, { populated = '', missing = '' } = {}) {
+ const dir = mkdtempSync(join(tmpdir(), 'guard-'));
+ try {
+ const binDir = join(dir, 'bin');
+ mkdirSync(binDir);
+ writeFileSync(join(binDir, 'package.json'), '{"type":"module"}');
+ bin(binDir, 'wrangler', FAKE_WRANGLER);
+ bin(binDir, 'node', FAKE_NODE);
+ const log = join(dir, 'calls.log');
+ writeFileSync(log, '');
+ const res = spawnSync(process.execPath, [SCRIPT, ...args], {
+ cwd: ROOT,
+ encoding: 'utf8',
+ timeout: 30_000,
+ env: {
+ ...process.env,
+ PATH: `${binDir}:${process.env.PATH}`,
+ GUARD_FAKE_LOG: log,
+ GUARD_FAKE_POPULATED: populated,
+ GUARD_FAKE_MISSING: missing,
+ SIGMA_D1_NAME: 'sigma-test-local',
+ },
+ });
+ const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+ return {
+ status: res.status,
+ stderr: res.stderr ?? '',
+ calls,
+ refused: /refusing --derive=full/.test(res.stderr ?? ''),
+ loadStarted: calls.some((c) => c[0] === 'node' && String(c[1]).includes('load-eop')),
+ // The guard's own query, not merely any --json call: the derive paths issue plenty of their own.
+ probe: calls.find((c) => /EXISTS\(SELECT 1 FROM contracts\)/.test(String(c[c.length - 1]))),
+ };
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+const PARTIAL = ['--from=2026-06-01', '--derive=full'];
+
+test('refuses a partial-window full derive over a populated corpus, before the load', () => {
+ const r = runImport(PARTIAL, { populated: 'contracts,lots,tenders,bidders,authorities' });
+ assert.equal(r.refused, true, r.stderr);
+ assert.equal(r.status, 1);
+ assert.equal(r.loadStarted, false, 'the load must not start after a refusal');
+ assert.match(r.stderr, /contracts/);
+});
+
+test('a corpus with NO contracts but populated tenders still refuses', () => {
+ // The regression this guard exists for. The previous call site asked COUNT(*) FROM contracts, so
+ // exactly this state - the one a half-failed run leaves behind - was waved through, and the rebuild
+ // would then drop tenders, bidders and authorities with nothing to reload them from.
+ const r = runImport(PARTIAL, { populated: 'tenders,bidders,authorities' });
+ assert.equal(r.refused, true, r.stderr);
+ assert.equal(r.status, 1);
+ assert.match(r.stderr, /tenders/);
+ assert.doesNotMatch(r.stderr, /including [^\n]*\bcontracts\b/);
+});
+
+test('refuses when the probe cannot answer for every cleared table', () => {
+ // A missing table makes safeD1 return nothing at all, which would otherwise read as "no corpus".
+ const r = runImport(PARTIAL, { populated: 'contracts', missing: 'facet_counts' });
+ assert.equal(r.status, 1);
+ assert.match(r.stderr, /could not read the corpus/);
+});
+
+test('an empty corpus is the initial backfill and passes', () => {
+ const r = runImport(PARTIAL, { populated: '' });
+ assert.equal(r.refused, false, r.stderr);
+ assert.equal(r.loadStarted, true, 'the load should start when there is nothing to lose');
+});
+
+test('a window reaching the start of the feed passes even over a full corpus', () => {
+ const r = runImport(['--from=2020-01-01', '--derive=full'], {
+ populated: 'contracts,authorities',
+ });
+ assert.equal(r.refused, false, r.stderr);
+ assert.equal(r.loadStarted, true);
+});
+
+test('a slice derive is never probed at all', () => {
+ const r = runImport(['--from=2026-06-01', '--derive=slice'], { populated: 'contracts' });
+ assert.equal(r.refused, false, r.stderr);
+ assert.equal(r.probe, undefined, 'slice derives must not pay for the corpus probe');
+});
+
+// Written out by hand ON PURPOSE. The first version of this test re-derived the list from
+// normalize-raw.sql using its own copy of the parser's regex — so the oracle inherited the parser's
+// blind spot, and rewriting one line as `DELETE FROM "search_index";` (valid SQLite, invisible in
+// review) dropped that table out of the probe with both suites still green. A list that shares the
+// implementation's assumptions cannot test them.
+const CLEARED = [
+ 'search_index',
+ 'flow_pairs',
+ 'company_totals',
+ 'authority_joint_participation',
+ 'authority_totals',
+ 'sector_totals',
+ 'facet_counts',
+ 'home_totals',
+ 'contract_co_authorities',
+ 'contracts',
+ 'lots',
+ 'tenders',
+ 'bidders',
+ 'authorities',
+];
+
+test('the probe covers every table the SQL clears, not a subset', () => {
+ const r = runImport(PARTIAL, { populated: 'contracts' });
+ const sql = String(r.probe[r.probe.indexOf('--command') + 1]);
+ for (const table of CLEARED) {
+ assert.match(sql, new RegExp(`FROM ${table}\\)`), `probe is missing ${table}`);
+ }
+});
+
+test('the hand-written list still matches what the SQL clears', () => {
+ // The other half of the cross-check: the list above holds the parser to account, and this holds the
+ // list to account. Either one drifting is caught here instead of in production. The matcher is
+ // deliberately loose about quoting so that a re-quoted table shows up as a MISMATCH rather than
+ // vanishing the way it did from the first version.
+ const sql = readFileSync(resolve(ROOT, 'scripts/normalize-raw.sql'), 'utf8');
+ const marker = sql.indexOf('-- @full-clear');
+ assert.notEqual(marker, -1, 'normalize-raw.sql lost its @full-clear marker');
+ const block = sql.slice(marker).split(/\r?\n\s*\r?\n/)[0];
+ const deletes = [...block.matchAll(/DELETE\s+FROM\s+(.+?)\s*;/gi)].map((m) =>
+ m[1].replace(/^["`[]/, '').replace(/["`\]]$/, ''),
+ );
+ assert.deepEqual(deletes, CLEARED);
+});
+
+test('a refusal leaves no transient staging behind', () => {
+ const r = runImport(PARTIAL, { populated: 'contracts' });
+ const files = r.calls.filter((c) => c.includes('--file')).map((c) => c[c.indexOf('--file') + 1]);
+ assert.ok(
+ files.some((f) => /drop-transient-staging\.sql$/.test(String(f))),
+ 'the guard should tear down the staging it found created',
+ );
+});
diff --git a/scripts/import.mjs b/scripts/import.mjs
index 578a1132..fe2aba16 100644
--- a/scripts/import.mjs
+++ b/scripts/import.mjs
@@ -14,9 +14,14 @@ import {
} from 'node:fs';
import { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { computeCatchupWindow, daysInWindow } from '../packages/ingest/src/ocds.ts';
+import {
+ computeCatchupWindow,
+ daysInWindow,
+ fullDeriveIsSafe,
+} from '../packages/ingest/src/ocds.ts';
import {
dropTransientStagingStatements,
+ fullClearTables,
refreshSliceStatementGroups,
} from '../packages/ingest/src/refresh.ts';
import { assertIntegrity } from './integrity-checks.mjs';
@@ -37,7 +42,6 @@ function reportAnomalies(runner, label) {
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const apiDir = resolve(root, 'apps/web');
const DEFAULT_FROM = '2020-01-01';
-const LARGE_GAP_DAYS = 14;
const DEFAULT_LOOKBACK_DAYS = 3;
const remote = process.argv.includes('--remote');
@@ -127,7 +131,8 @@ function safeD1(sql) {
try {
return d1(sql);
} catch (err) {
- const msg = String(err?.message ?? err);
+ // wrangler writes the SQLITE error to stdout, not the exception message.
+ const msg = `${err?.message ?? err} ${err?.stdout ?? ''} ${err?.stderr ?? ''}`;
if (/no such table|does not exist/i.test(msg)) return [];
throw err;
}
@@ -215,12 +220,11 @@ function resolveCatchupPlan() {
const to = String(arg('to') || window.to);
const gapDays = daysInWindow(from, to);
const requestedDerive = arg('derive');
- const derive =
- requestedDerive && requestedDerive !== true
- ? String(requestedDerive)
- : gapDays > LARGE_GAP_DAYS
- ? 'full'
- : 'slice';
+ // The catch-up window is gap-aware, so it only ever covers the tail of the feed. A full derive
+ // rebuilds `contracts` from staging (normalize-raw.sql opens with DELETE FROM contracts), which
+ // would drop every contract older than the window — so catch-up always derives a slice, however
+ // wide the gap. An operator who really has loaded the whole feed can still pass --derive=full.
+ const derive = requestedDerive && requestedDerive !== true ? String(requestedDerive) : 'slice';
return { from, to, maxLoadedDate, gapDays, derive };
}
@@ -229,7 +233,64 @@ function validateDeriveMode(mode) {
throw new Error(`unknown --derive=${mode}; expected full|slice`);
}
+// Refuse the one combination that silently destroys data: a full derive (which rebuilds the domain
+// from staging) driven by a window that does not reach back to the start of the feed. Anything the
+// window misses is deleted and never reloaded. Checked before the load, and the refusal tears the
+// transient staging back down: it cannot run any earlier, because the catch-up plan reads
+// raw_contracts, but it must not leave a half-built schema behind for a run that never started.
+//
+// The question is asked of EVERY table the full clear empties, not of `contracts` alone: a corpus
+// with no contracts but populated tenders, bidders or authorities is exactly the state a half-failed
+// run leaves behind, and the narrow-window rebuild would then wipe those too while the guard waved
+// it through. The list comes out of normalize-raw.sql itself (see @full-clear there).
+function assertDeriveWindowSafe(mode, from) {
+ if (mode !== 'full') return;
+ const tables = fullClearTables(readFileSync(resolve(root, 'scripts/normalize-raw.sql'), 'utf8'));
+ if (tables.length === 0)
+ throw new Error(
+ 'normalize-raw.sql has no @full-clear block — refusing to guess what it clears',
+ );
+ // EXISTS per table, not COUNT(*) over the union: this runs on every full derive and must stay
+ // cheap on a corpus of hundreds of thousands of rows.
+ const probe = tables.map((t) => `(SELECT EXISTS(SELECT 1 FROM ${t})) AS "${t}"`).join(', ');
+ const row = safeD1(`SELECT ${probe}`)[0];
+ // safeD1 turns a missing table into an empty result, which would otherwise read as "no corpus" and
+ // wave the destructive path through — one absent table blinding the guard about the other thirteen.
+ // A probe that could not answer is not an answer: fail closed.
+ if (!row || tables.some((table) => !(table in row))) {
+ console.error(
+ `!! refusing --derive=full: could not read the corpus. Every table normalize-raw.sql clears ` +
+ `(${tables.join(', ')}) must be answerable before a rebuild may drop it.`,
+ );
+ execSqlStatements(dropTransientStagingStatements(), 'drop-transient-staging');
+ process.exit(1);
+ }
+ const populated = Object.entries(row)
+ .filter(([, present]) => Number(present) > 0)
+ .map(([table]) => table);
+ if (
+ fullDeriveIsSafe({ windowFrom: from, feedStart: DEFAULT_FROM, hasCorpus: populated.length > 0 })
+ )
+ return;
+ console.error(
+ `!! refusing --derive=full: the load window starts ${from}, but the corpus is already ` +
+ `populated back to ${DEFAULT_FROM}.\n` +
+ ` A full derive rebuilds the domain from staging, so everything before ${from} would be ` +
+ `dropped and not reloaded — including ${populated.join(', ')}.\n` +
+ ` Use --derive=slice for an incremental refresh, or reload the whole feed with ` +
+ `--from=${DEFAULT_FROM}.`,
+ );
+ execSqlStatements(dropTransientStagingStatements(), 'drop-transient-staging');
+ process.exit(1);
+}
+
async function runFullDerive() {
+ // #306: link namespace-mismatched EOP annexes by value BEFORE derive-amendments.sql (its prefer-EOP
+ // dedup would otherwise resurrect OCDS twins — review todorkolev #1). This STANDALONE script is the
+ // full-derive form (candidates from the whole re-staged raw_contracts). The daily/slice + Worker path runs
+ // an equivalent value anchor inside refresh-slice.sql that draws candidates from the served `contracts`
+ // corpus instead of the window (so "unique on the procedure" is corpus-wide — review nikimilenkov HIGH 1).
+ execSql(resolve(root, 'scripts/resolve-amendment-contracts.sql'));
execSql(resolve(root, 'scripts/derive-amendments.sql'));
run('node', ['scripts/load-fx.mjs', '--apply', ...passthru]);
execSql(resolve(root, 'scripts/load-nuts.sql'));
@@ -307,6 +368,8 @@ async function runWorkBackfill() {
`--out=${resolve(workDir, `${stem}.eop-load.sql`)}`,
...loadFlags,
]);
+ // #306: value-anchor resolver runs first on the full-derive path — see runFullDerive.
+ sqliteFile(workDb, resolve(root, 'scripts/resolve-amendment-contracts.sql'));
sqliteFile(workDb, resolve(root, 'scripts/derive-amendments.sql'));
run('node', [
'scripts/load-fx.mjs',
@@ -370,19 +433,24 @@ if (arg('work-db') !== undefined) {
console.log(`==> Sigma import (${remote ? 'REMOTE' : 'local'})`);
run('wrangler', ['d1', 'migrations', 'apply', d1Name, loc, ...d1PersistArgs], apiDir);
execSqlStatements(dropTransientStagingStatements(), 'drop-stale-transient-staging');
+// Must precede resolveCatchupPlan(): latestLoadedDate() reads raw_contracts, which lives here.
execSql(resolve(root, 'scripts/work-staging-schema.sql'));
let deriveMode = String(arg('derive') || 'full');
let loadFlags = explicitRangeFlags();
+// Mirrors load-eop.mjs, which also falls back to DEFAULT_FROM when no --from is given.
+let windowFrom = String(arg('from') || DEFAULT_FROM);
if (catchup) {
const plan = resolveCatchupPlan();
deriveMode = plan.derive;
loadFlags = rangeFlags(plan.from, plan.to);
+ windowFrom = plan.from;
console.log(
`==> catchup window ${plan.from}..${plan.to} (${plan.gapDays} days, latest=${plan.maxLoadedDate || 'none'}, derive=${deriveMode})`,
);
}
validateDeriveMode(deriveMode);
+assertDeriveWindowSafe(deriveMode, windowFrom);
run('node', ['scripts/load-eop.mjs', '--apply', ...loadFlags, ...passthru]);
if (deriveMode === 'slice') await runSliceDerive();
diff --git a/scripts/integrity-checks.d.mts b/scripts/integrity-checks.d.mts
index d358d0af..82a6207e 100644
--- a/scripts/integrity-checks.d.mts
+++ b/scripts/integrity-checks.d.mts
@@ -30,6 +30,7 @@ export function checkNoNegativeValues(runner: IntegrityRunner): Promise;
export function checkDateSanity(runner: IntegrityRunner): Promise;
export function checkStagingReconciliation(runner: IntegrityRunner): Promise;
+export function checkAmendmentTwins(runner: IntegrityRunner): Promise;
export const CHECKS: Array<(runner: IntegrityRunner) => Promise>;
export function runIntegrityChecks(runner: IntegrityRunner): Promise;
diff --git a/scripts/integrity-checks.mjs b/scripts/integrity-checks.mjs
index b48c4912..e87a57de 100644
--- a/scripts/integrity-checks.mjs
+++ b/scripts/integrity-checks.mjs
@@ -372,6 +372,38 @@ export async function checkStagingReconciliation(runner) {
};
}
+// 7) Amendment twin dedup (#286). The OCDS→EOP bridge lets an OCDS amendment reach the same contract as
+// its EOP twin; the prefer-EOP dedup keeps only one per (unp, contract_number) by DELETE-ing OCDS rows
+// before promotion — the SOLE guard, since promotion is unconditional. On the incremental path a twin
+// can straddle windows (EOP served earlier, OCDS arriving later), so the slice dedup reconciles against
+// the served table; this gate is the post-condition of that (and of the full path): no (unp,
+// contract_number) may carry BOTH an EOP and an OCDS served amendment, or annex_count double-counts.
+export async function checkAmendmentTwins(runner) {
+ const name = 'amendment-twin-dedup';
+ if (!(await tableExists(runner, 'amendments')))
+ return { name, ok: true, skipped: true, detail: 'amendments table absent' };
+ const n = num(
+ await scalar(
+ runner,
+ 'SELECT COUNT(*) AS n FROM (' +
+ 'SELECT unp, contract_number FROM amendments ' +
+ 'WHERE unp IS NOT NULL AND contract_number IS NOT NULL ' +
+ 'GROUP BY unp, contract_number ' +
+ "HAVING SUM(source LIKE 'eop:%') > 0 AND SUM(source LIKE 'ocds:%') > 0)",
+ 'n',
+ ),
+ );
+ return {
+ name,
+ ok: n === 0,
+ skipped: false,
+ detail:
+ n === 0
+ ? 'no (unp, contract_number) carries both an EOP and an OCDS amendment (prefer-EOP dedup intact)'
+ : `${n} (unp, contract_number) carry both an EOP and an OCDS amendment — prefer-EOP dedup regressed and annex_count double-counts (#286)`,
+ };
+}
+
export const CHECKS = [
checkNonEmptyCorpus,
checkRollupReconciliation,
@@ -380,6 +412,7 @@ export const CHECKS = [
checkEikValidity,
checkDateSanity,
checkStagingReconciliation,
+ checkAmendmentTwins,
];
export async function runIntegrityChecks(runner) {
diff --git a/scripts/normalize-raw.sql b/scripts/normalize-raw.sql
index ebfef975..246e91ec 100644
--- a/scripts/normalize-raw.sql
+++ b/scripts/normalize-raw.sql
@@ -31,6 +31,10 @@
-- every contract has a parent. bids stays empty (the data has a bid COUNT, not bids).
-- Full clear in child→parent order (D1 enforces FKs).
+-- @full-clear — everything emptied between this marker and the blank line below is rebuilt from
+-- staging alone, so a full derive driven by a partial window loses whatever the window misses.
+-- scripts/import.mjs reads the list from here to decide whether refusing is warranted; keep new
+-- DELETEs inside the block so the guard picks them up on its own.
DROP TABLE IF EXISTS joint_tender_leads;
DROP TABLE IF EXISTS unp_prefix_authorities;
DROP TABLE IF EXISTS joint_authority_members;
@@ -690,7 +694,8 @@ SET ownership_kind = (
-- 5) Contracts — awarded lines (1:1 with staging rows), linked to tender + winning bidder,
-- with the data-quality verdict (see 0007_data_quality.sql):
--- value_flag = 'value_suspect' effective value >2bn EUR, or >200× the procedure estimate
+-- value_flag = 'value_suspect' effective value >2bn EUR, >200× the procedure estimate, or
+-- inside the 95×–105× стотинки band (a dropped decimal point)
-- when that estimate is at least 1000 EUR — repaired to the
-- procedure estimate for sums/display
-- | 'value_low' zero/negative, OR a tiny signed value (< 1000 EUR) that is also
@@ -698,7 +703,9 @@ SET ownership_kind = (
-- LABELLED — large legitimate framework call-offs (a small share of
-- a huge ceiling but big in absolute terms) are excluded by the
-- < 1000 EUR floor, so they keep counting and stay unflagged
--- | 'annex_suspect' amendment pushed current_value ≥100× signing, or negative →
+-- | 'annex_suspect' amendment pushed current_value ≥100× signing, or negative, or
+-- a single annex step jumped ≥10× while the aggregate ended ≥5×
+-- over signing (a mis-keyed annex value) →
-- fall back to signing_value, or current_value if signing is
-- missing, so the contract still counts
-- | 'review' ≥10× the procedure estimate (kept, but flagged)
@@ -725,7 +732,10 @@ INSERT OR IGNORE INTO contracts
-- travels alongside the value it minted — computed ONCE over raw_amendments, not per-row).
WITH amendment_dedup AS (
SELECT *,
- 'am:' || COALESCE(unp, '') || ':' || COALESCE(contract_number, '') || ':' ||
+ -- #306: key on the original annex-side number (contract_number_raw) when the value resolver rewrote a
+ -- row — must match derive-amendments.sql / promote-amendments.sql byte-for-byte (review nikimilenkov
+ -- MEDIUM 3). NULL raw → the plain number, so unresolved rows are unaffected.
+ 'am:' || COALESCE(unp, '') || ':' || COALESCE(NULLIF(contract_number_raw, ''), contract_number, '') || ':' ||
COALESCE(
NULLIF(document_number, ''),
NULLIF(correction_number, ''),
@@ -812,6 +822,7 @@ FROM (
CASE y.value_flag
WHEN 'value_suspect' THEN y.proc_est_native
WHEN 'annex_suspect' THEN COALESCE(y.signing_value, y.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(y.signing_value, y.current_value)
ELSE COALESCE(y.current_value, y.signing_value)
END AS display_native,
-- value_suspect is repaired directly from proc_est_eur in the outer amount_eur CASE; value_low and
@@ -819,6 +830,7 @@ FROM (
CASE y.value_flag
WHEN 'value_suspect' THEN NULL
WHEN 'annex_suspect' THEN COALESCE(y.signing_value, y.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(y.signing_value, y.current_value)
ELSE COALESCE(y.current_value, y.signing_value)
END AS trusted_native,
-- Keep the companion currency paired with the exact native value chosen above. In particular,
@@ -830,6 +842,10 @@ FROM (
WHEN y.signing_value IS NOT NULL THEN COALESCE(NULLIF(y.currency, ''), 'BGN')
ELSE COALESCE(NULLIF(y.amendment_currency, ''), NULLIF(y.currency, ''), 'BGN')
END
+ WHEN 'annex_total_suspect' THEN CASE
+ WHEN y.signing_value IS NOT NULL THEN COALESCE(NULLIF(y.currency, ''), 'BGN')
+ ELSE COALESCE(NULLIF(y.amendment_currency, ''), NULLIF(y.currency, ''), 'BGN')
+ END
ELSE CASE
WHEN y.current_value IS NOT NULL THEN COALESCE(NULLIF(y.amendment_currency, ''), NULLIF(y.currency, ''), 'BGN')
ELSE COALESCE(NULLIF(y.currency, ''), 'BGN')
@@ -875,7 +891,20 @@ FROM (
CASE
-- Over-valuation + absurd are checked FIRST and repaired to the procedure estimate.
-- value_low is a labelled-but-counted flag (see the amount_eur CASE).
- WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND c.eff_eur > 200 * c.proc_est_eur) THEN 'value_suspect'
+ WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND (c.eff_eur > 200 * c.proc_est_eur
+ -- Dropped decimal point: the value was entered in стотинки, so it lands at almost exactly
+ -- 100x the procedure estimate. Real overruns spread out; this is an isolated cluster with
+ -- nothing between 105x and 200x, so the band is narrow on purpose.
+ OR (c.eff_eur >= 95 * c.proc_est_eur AND c.eff_eur <= 105 * c.proc_est_eur)))
+ -- The same dropped decimal point, but on a LOT of a multi-lot procedure: there the signature
+ -- is 100x the lot's OWN estimate, and the ratio to the whole procedure lands wherever the
+ -- lot's share puts it (issue #247 reports one at 89.8x). Measuring against the own-row
+ -- estimate alone would be unsafe - for framework and unit-price procedures it is a UNIT
+ -- price and a whole call-off legitimately dwarfs it - so it is paired with the procedure
+ -- level condition that already means "implausibly large for this procedure" (>= 10x, the
+ -- review threshold). A unit-price call-off sits at ~1x the procedure estimate and is spared.
+ OR (c.own_est_eur >= 1000 AND c.proc_est_eur >= 1000 AND c.eff_eur >= 10 * c.proc_est_eur
+ AND c.eff_eur >= 95 * c.own_est_eur AND c.eff_eur <= 105 * c.own_est_eur) THEN 'value_suspect'
-- value_low: zero/negative, OR a tiny signed value (< 1000 EUR) that is also < 5% of the
-- estimate. Large legitimate framework call-offs (small share of a huge ceiling but big in
-- absolute terms) are NOT caught — the < 1000 EUR floor keeps them OUT of value_low.
@@ -923,7 +952,95 @@ FROM (
)
END
), 0) < 0.05 THEN 'value_low'
- WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND c.current_value / c.signing_value >= 100)) THEN 'annex_suspect'
+ WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND (c.current_value / c.signing_value >= 100
+ -- Mis-keyed annex: a single step jumped ≥10× AND the aggregate ended ≥5× over signing.
+ -- The step alone is NOT enough — some chains have a huge step that a later annex pulls
+ -- back below signing, and flagging those would RAISE the shown value, not repair it.
+ OR (c.current_value / c.signing_value >= 5 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ AND am.value_before > 0 AND am.value_after >= 10 * am.value_before
+ ))))) THEN 'annex_suspect'
+ -- #305 value double-count: a driving annex reports a new TOTAL added to the old instead of
+ -- replacing it, so value_after ≈ 2× the OLD total. ЗОП чл.116 caps a single amendment at +50%,
+ -- so one step cannot legally more than double a contract — the ≥2× single step IS the defect
+ -- signal, wherever it sits in the chain. Scope: value_after in [2×,10×) a base that value_before
+ -- ties to a KNOWN prior total — signing_value OR a preceding annex's value_after (the multi-annex
+ -- case) — same currency. Slow legitimate climbs never reach ≥2× so stay untouched; the ≥10×
+ -- mis-key is #299's annex_suspect above; cross-currency doubles are an FX artefact ('review');
+ -- and the ABS(... - current_value) tie binds this to the annex that DRIVES current_value, so a
+ -- doubled annex later superseded by a correct one is NOT flagged.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ -- #305 Tier-2: a text-treated annex (restated total or confirmed-genuine increment) is NOT an
+ -- arithmetic suspect — value_treatment IS NOT NULL means the основание text already resolved it.
+ AND am.value_treatment IS NULL
+ -- #305 multi-annex: the doubled step need not be the FIRST annex. value_before may be a
+ -- prior cumulative total (a preceding annex's value_after), not signing. Anchor to signing
+ -- OR a legitimately-grown prior total (a prior annex that was itself not a double), while a
+ -- single ≥2× step (ЗОП чл.116 caps one amendment at +50%) is the defect wherever it sits.
+ AND am.value_before > 0 AND (
+ ABS(am.value_before - c.signing_value) < 0.01 * c.signing_value
+ OR EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double),
+ -- so a compounding chain where every step doubles is left untouched, not restated.
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: an EXACT single-step 2× (value_after ≈ 2× value_before) on an ORPHAN
+ -- base — value_before ties neither signing NOR any prior annex's value_after (e.g. contract
+ -- 84818, whose annex base 76.77M is unrelated to the contract's values). A legal +100% in one
+ -- amendment is impossible (ЗОП чл.116), so flag → signing fallback (EXCLUDE); never REWRITES
+ -- (that stays gated, #307 HIGH-2). The orphan guard leaves a legitimate compounding-doubling
+ -- chain (each step's base ties the prior step) untouched, exactly as the legit-prior arm does.
+ OR (
+ ABS(am.value_after - 2 * am.value_before) < 0.005 * am.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ )
+ )
+ AND am.value_after >= 2 * am.value_before AND am.value_after < 10 * am.value_before
+ -- #305 M2: the double-count model presupposes a self-consistent row (value_after ≈
+ -- value_before + value_delta). If value_delta is present and contradicts that, the model
+ -- provably does not apply — do NOT flag (mirrors the TS classifier's a≈b+d precondition).
+ AND (am.value_delta IS NULL
+ OR ABS(am.value_after - (am.value_before + am.value_delta)) < 0.01 * am.value_after)
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) THEN 'annex_total_suspect'
+ -- #305 NEW-HIGH-1 (multi-annex chain contamination): the double-count correction is per-row and
+ -- does NOT propagate down a chain. When a PRIOR annex was double-count corrected (value_after was
+ -- doubled, restated down), a LATER annex still arrives from the feed computed on the CONTAMINATED
+ -- (raw, doubled) base. Its own step ratio is legitimate (<2×) so the gate above misses it, and the
+ -- prior annex is text-treated so it is excluded too — yet current_value inherited the doubled
+ -- total. Detect the driving annex whose value_before ties to a prior annex's RAW value_after where
+ -- that prior was restated to a lower total, and flag → signing fallback (an honest exclusion beats
+ -- a served overstatement) until per-chain value_before propagation (a follow-up) recomputes it.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ AND am.value_treatment IS NULL
+ AND am.value_before > 0
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after_restated IS NOT NULL
+ AND prev.value_after_restated < prev.value_after
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) THEN 'annex_total_suspect'
WHEN c.proc_est_eur > 0 AND c.eff_eur >= 10 * c.proc_est_eur THEN 'review'
ELSE 'ok'
END AS value_flag,
@@ -976,6 +1093,24 @@ FROM (
LIMIT 1
)
END AS proc_est_eur,
+ -- Own-row (per-lot) estimate in EUR. The "too high" flags deliberately measure against the
+ -- PROCEDURE estimate, because for framework and unit-price procedures this per-row number is a
+ -- UNIT price and a whole call-off legitimately dwarfs it. It is computed here ONLY for the
+ -- стотинки band, which pairs it with a procedure-level condition for exactly that reason.
+ CASE
+ WHEN c.estimated_value IS NULL THEN NULL
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'EUR' THEN c.estimated_value
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'BGN' THEN c.estimated_value / 1.95583
+ ELSE c.estimated_value * (
+ SELECT f.eur_per_unit
+ FROM fx_rates f
+ WHERE f.base_currency = COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''))
+ AND f.rate_date <= c.contract_date
+ AND f.rate_date >= date(c.contract_date, '-10 days')
+ ORDER BY f.rate_date DESC
+ LIMIT 1
+ )
+ END AS own_est_eur,
t.estimated_value AS proc_est_native,
aw.currency AS amendment_currency
FROM raw_contracts c
@@ -1173,7 +1308,10 @@ CREATE TABLE IF NOT EXISTS pipeline_stats (
DELETE FROM pipeline_stats;
WITH amendment_dedup AS (
SELECT *,
- 'am:' || COALESCE(unp, '') || ':' || COALESCE(contract_number, '') || ':' ||
+ -- #306: key on the original annex-side number (contract_number_raw) when the value resolver rewrote a
+ -- row — must match derive-amendments.sql / promote-amendments.sql byte-for-byte (review nikimilenkov
+ -- MEDIUM 3). NULL raw → the plain number, so unresolved rows are unaffected.
+ 'am:' || COALESCE(unp, '') || ':' || COALESCE(NULLIF(contract_number_raw, ''), contract_number, '') || ':' ||
COALESCE(
NULLIF(document_number, ''),
NULLIF(correction_number, ''),
@@ -1206,7 +1344,20 @@ SELECT 1,
SELECT c.*,
CASE
-- Mirrors the main derive CASE above; value_low is checked AFTER over-valuation + absurd.
- WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND c.eff_eur > 200 * c.proc_est_eur) THEN 'value_suspect'
+ WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND (c.eff_eur > 200 * c.proc_est_eur
+ -- Dropped decimal point: the value was entered in стотинки, so it lands at almost exactly
+ -- 100x the procedure estimate. Real overruns spread out; this is an isolated cluster with
+ -- nothing between 105x and 200x, so the band is narrow on purpose.
+ OR (c.eff_eur >= 95 * c.proc_est_eur AND c.eff_eur <= 105 * c.proc_est_eur)))
+ -- The same dropped decimal point, but on a LOT of a multi-lot procedure: there the signature
+ -- is 100x the lot's OWN estimate, and the ratio to the whole procedure lands wherever the
+ -- lot's share puts it (issue #247 reports one at 89.8x). Measuring against the own-row
+ -- estimate alone would be unsafe - for framework and unit-price procedures it is a UNIT
+ -- price and a whole call-off legitimately dwarfs it - so it is paired with the procedure
+ -- level condition that already means "implausibly large for this procedure" (>= 10x, the
+ -- review threshold). A unit-price call-off sits at ~1x the procedure estimate and is spared.
+ OR (c.own_est_eur >= 1000 AND c.proc_est_eur >= 1000 AND c.eff_eur >= 10 * c.proc_est_eur
+ AND c.eff_eur >= 95 * c.own_est_eur AND c.eff_eur <= 105 * c.own_est_eur) THEN 'value_suspect'
WHEN COALESCE(c.current_value, c.signing_value) <= 0 THEN 'value_low'
WHEN c.estimated_value > 0 AND c.signing_value IS NOT NULL AND (
CASE
@@ -1251,7 +1402,95 @@ SELECT 1,
)
END
), 0) < 0.05 THEN 'value_low'
- WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND c.current_value / c.signing_value >= 100)) THEN 'annex_suspect'
+ WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND (c.current_value / c.signing_value >= 100
+ -- Mis-keyed annex: a single step jumped ≥10× AND the aggregate ended ≥5× over signing.
+ -- The step alone is NOT enough — some chains have a huge step that a later annex pulls
+ -- back below signing, and flagging those would RAISE the shown value, not repair it.
+ OR (c.current_value / c.signing_value >= 5 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ AND am.value_before > 0 AND am.value_after >= 10 * am.value_before
+ ))))) THEN 'annex_suspect'
+ -- #305 value double-count: a driving annex reports a new TOTAL added to the old instead of
+ -- replacing it, so value_after ≈ 2× the OLD total. ЗОП чл.116 caps a single amendment at +50%,
+ -- so one step cannot legally more than double a contract — the ≥2× single step IS the defect
+ -- signal, wherever it sits in the chain. Scope: value_after in [2×,10×) a base that value_before
+ -- ties to a KNOWN prior total — signing_value OR a preceding annex's value_after (the multi-annex
+ -- case) — same currency. Slow legitimate climbs never reach ≥2× so stay untouched; the ≥10×
+ -- mis-key is #299's annex_suspect above; cross-currency doubles are an FX artefact ('review');
+ -- and the ABS(... - current_value) tie binds this to the annex that DRIVES current_value, so a
+ -- doubled annex later superseded by a correct one is NOT flagged.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ -- #305 Tier-2: a text-treated annex (restated total or confirmed-genuine increment) is NOT an
+ -- arithmetic suspect — value_treatment IS NOT NULL means the основание text already resolved it.
+ AND am.value_treatment IS NULL
+ -- #305 multi-annex: the doubled step need not be the FIRST annex. value_before may be a
+ -- prior cumulative total (a preceding annex's value_after), not signing. Anchor to signing
+ -- OR a legitimately-grown prior total (a prior annex that was itself not a double), while a
+ -- single ≥2× step (ЗОП чл.116 caps one amendment at +50%) is the defect wherever it sits.
+ AND am.value_before > 0 AND (
+ ABS(am.value_before - c.signing_value) < 0.01 * c.signing_value
+ OR EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double),
+ -- so a compounding chain where every step doubles is left untouched, not restated.
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: an EXACT single-step 2× (value_after ≈ 2× value_before) on an ORPHAN
+ -- base — value_before ties neither signing NOR any prior annex's value_after (e.g. contract
+ -- 84818, whose annex base 76.77M is unrelated to the contract's values). A legal +100% in one
+ -- amendment is impossible (ЗОП чл.116), so flag → signing fallback (EXCLUDE); never REWRITES
+ -- (that stays gated, #307 HIGH-2). The orphan guard leaves a legitimate compounding-doubling
+ -- chain (each step's base ties the prior step) untouched, exactly as the legit-prior arm does.
+ OR (
+ ABS(am.value_after - 2 * am.value_before) < 0.005 * am.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ )
+ )
+ AND am.value_after >= 2 * am.value_before AND am.value_after < 10 * am.value_before
+ -- #305 M2: the double-count model presupposes a self-consistent row (value_after ≈
+ -- value_before + value_delta). If value_delta is present and contradicts that, the model
+ -- provably does not apply — do NOT flag (mirrors the TS classifier's a≈b+d precondition).
+ AND (am.value_delta IS NULL
+ OR ABS(am.value_after - (am.value_before + am.value_delta)) < 0.01 * am.value_after)
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) THEN 'annex_total_suspect'
+ -- #305 NEW-HIGH-1 (multi-annex chain contamination): the double-count correction is per-row and
+ -- does NOT propagate down a chain. When a PRIOR annex was double-count corrected (value_after was
+ -- doubled, restated down), a LATER annex still arrives from the feed computed on the CONTAMINATED
+ -- (raw, doubled) base. Its own step ratio is legitimate (<2×) so the gate above misses it, and the
+ -- prior annex is text-treated so it is excluded too — yet current_value inherited the doubled
+ -- total. Detect the driving annex whose value_before ties to a prior annex's RAW value_after where
+ -- that prior was restated to a lower total, and flag → signing fallback (an honest exclusion beats
+ -- a served overstatement) until per-chain value_before propagation (a follow-up) recomputes it.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ AND am.value_treatment IS NULL
+ AND am.value_before > 0
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after_restated IS NOT NULL
+ AND prev.value_after_restated < prev.value_after
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) THEN 'annex_total_suspect'
WHEN c.proc_est_eur > 0 AND c.eff_eur >= 10 * c.proc_est_eur THEN 'review'
ELSE 'ok'
END AS value_flag,
@@ -1297,6 +1536,24 @@ SELECT 1,
LIMIT 1
)
END AS proc_est_eur,
+ -- Own-row (per-lot) estimate in EUR. The "too high" flags deliberately measure against the
+ -- PROCEDURE estimate, because for framework and unit-price procedures this per-row number is a
+ -- UNIT price and a whole call-off legitimately dwarfs it. It is computed here ONLY for the
+ -- стотинки band, which pairs it with a procedure-level condition for exactly that reason.
+ CASE
+ WHEN c.estimated_value IS NULL THEN NULL
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'EUR' THEN c.estimated_value
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'BGN' THEN c.estimated_value / 1.95583
+ ELSE c.estimated_value * (
+ SELECT f.eur_per_unit
+ FROM fx_rates f
+ WHERE f.base_currency = COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''))
+ AND f.rate_date <= c.contract_date
+ AND f.rate_date >= date(c.contract_date, '-10 days')
+ ORDER BY f.rate_date DESC
+ LIMIT 1
+ )
+ END AS own_est_eur,
t.estimated_value AS proc_est_native,
aw.currency AS amendment_currency
FROM raw_contracts c
@@ -1318,6 +1575,7 @@ SELECT 1,
) c
WHERE CASE c.value_flag
WHEN 'annex_suspect' THEN COALESCE(c.signing_value, c.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(c.signing_value, c.current_value)
ELSE COALESCE(c.current_value, c.signing_value)
END IS NOT NULL
AND EXISTS (SELECT 1 FROM tenders te WHERE te.id = 't:' || c.unp)
@@ -1345,6 +1603,7 @@ SELECT
(SELECT contract_candidates FROM pipeline_stats) AS contract_candidates,
(SELECT COUNT(*) FROM contracts WHERE value_flag = 'value_suspect') AS value_suspect,
(SELECT COUNT(*) FROM contracts WHERE value_flag = 'annex_suspect') AS annex_suspect,
+ (SELECT COUNT(*) FROM contracts WHERE value_flag = 'annex_total_suspect') AS annex_total_suspect,
(SELECT COUNT(*) FROM contracts WHERE value_flag = 'review') AS review,
(SELECT COUNT(*) FROM contracts WHERE fx_converted = 1) AS fx_converted,
(SELECT ROUND(SUM(amount_eur) / 1e9, 2) FROM contracts) AS clean_total_eur_bn,
diff --git a/scripts/precompute.sql b/scripts/precompute.sql
index c79a26ef..f93a0a79 100644
--- a/scripts/precompute.sql
+++ b/scripts/precompute.sql
@@ -22,8 +22,9 @@
-- signing/current in EUR for the contract page's estimated→signing→current strip.
-- BGN at the fixed peg (÷1.95583), EUR as-is, foreign at the row's stored fx_rate (eur_per_unit).
-- Display rule: NULL where the figure is suspect, so the caller renders „данните се преглеждат",
--- never a fabricated number. signing suppressed for value_suspect; current suppressed for value_ or
--- annex_suspect (the suspect annex is the bad part). estimated_value_eur is derived per-request on
+-- never a fabricated number. signing suppressed for value_suspect; current suppressed for value_,
+-- annex_suspect or annex_total_suspect (#305; the suspect annex is the bad part). estimated_value_eur
+-- is derived per-request on
-- the contract detail loader from the tender (procurement-level, shared across a multi-lot prepiska).
UPDATE contracts SET
signing_value_eur = CASE
@@ -33,7 +34,7 @@ UPDATE contracts SET
WHEN fx_rate IS NOT NULL THEN signing_value * fx_rate
ELSE NULL END,
current_value_eur = CASE
- WHEN value_flag IN ('value_suspect','annex_suspect') OR current_value IS NULL THEN NULL
+ WHEN value_flag IN ('value_suspect','annex_suspect','annex_total_suspect') OR current_value IS NULL THEN NULL
WHEN COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN') = 'EUR' THEN current_value
WHEN COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN') = 'BGN' THEN current_value / 1.95583
WHEN fx_rate IS NOT NULL THEN current_value * fx_rate
@@ -245,6 +246,13 @@ FROM interest_links il JOIN persons p ON p.id = il.person_id
-- rendering both re-identifies the relative via a ТР owner lookup, and the company is already surfaced
-- by the self row.
WHERE il.status = 'published' AND il.interest_class IN ('private_ownership', 'family_ownership')
+ -- …and the identity rests on a Trade Register fact (#279, ADR-0033). This predicate is the THIRD copy
+ -- of the surface gate — the other two are SURFACED_OWNERSHIP in packages/db/src/queries/related-persons.ts
+ -- and the sibling block in the other of precompute.sql / refresh-slice.sql. All three must move
+ -- together: this one feeds the officials search index, so omitting it would keep officials findable
+ -- whose links no longer surface.
+ AND EXISTS (SELECT 1 FROM interest_link_evidence e
+ WHERE e.link_key = il.link_key AND e.evidence_kind IN ('document','confirmed'))
AND EXISTS (SELECT 1 FROM contracts cc JOIN bidders bb ON bb.id = cc.bidder_id
WHERE bb.eik_normalized = il.eik)
AND NOT (il.interest_class = 'family_ownership' AND EXISTS (
diff --git a/scripts/promote-amendments.sql b/scripts/promote-amendments.sql
index 5d72e367..c5e70557 100644
--- a/scripts/promote-amendments.sql
+++ b/scripts/promote-amendments.sql
@@ -7,13 +7,18 @@
DELETE FROM amendments;
INSERT OR REPLACE INTO amendments (
- id, natural_key, contract_number, unp, value_before, value_after, value_delta, currency,
- published_at, document_number, description, source
+ id, natural_key, contract_number, contract_number_raw, link_method, unp,
+ value_before, value_after, value_delta, currency,
+ published_at, document_number, description, source,
+ value_restated, value_treatment, value_suspect
)
WITH keyed AS (
SELECT
*,
- 'am:' || COALESCE(unp, '') || ':' || COALESCE(contract_number, '') || ':' ||
+ -- #306: key on the original annex-side number (contract_number_raw) when the value resolver rewrote a
+ -- row — MUST match derive-amendments.sql's rollup key byte-for-byte, else annex_count and the served
+ -- timeline disagree (review nikimilenkov MEDIUM 3). NULL raw → the plain number.
+ 'am:' || COALESCE(unp, '') || ':' || COALESCE(NULLIF(contract_number_raw, ''), contract_number, '') || ':' ||
COALESCE(
NULLIF(document_number, ''),
NULLIF(correction_number, ''),
@@ -38,15 +43,66 @@ SELECT
natural_key,
natural_key,
contract_number,
+ contract_number_raw, -- #306 provenance: the annex-side number before the value resolver rewrote it
+ link_method, -- #306 provenance: 'value_anchor' for value-linked rows, else NULL
unp,
value_before,
- value_after,
- value_delta,
+ -- #305 Tier-2: serve the effective (text-corrected) after and a self-consistent delta; a restated
+ -- annex carries the true total, an untreated one is unchanged.
+ COALESCE(value_after_restated, value_after),
+ COALESCE(value_after_restated, value_after) - value_before,
currency,
published_at,
document_number,
description,
- source
+ source,
+ CASE WHEN value_after_restated IS NOT NULL THEN 1 ELSE 0 END,
+ value_treatment,
+ -- #305 residual: mark a suspected double-count that is NOT already text-treated so the UI suppresses
+ -- the untrusted value_after. Mirrors normalize-raw.sql's annex_total_suspect arithmetic gate, but
+ -- joined to raw_contracts for the contract's signing_value/currency (this served INSERT has no
+ -- contract row to read). value_treatment IS NULL keeps a restated/genuine row out (value_restated
+ -- already owns those). No current_value tie here: the tie in normalize-raw only decides whether the
+ -- CONTRACT is flagged; the per-row marker suppresses any row whose after is an unbridgeable double.
+ CASE WHEN value_treatment IS NULL
+ AND value_before > 0
+ AND value_after >= 2 * value_before AND value_after < 10 * value_before
+ -- #305 M2 self-consistency: skip when value_delta is present and a ≉ b + d (model N/A).
+ AND (value_delta IS NULL OR ABS(value_after - (value_before + value_delta)) < 0.01 * value_after)
+ AND EXISTS (
+ SELECT 1 FROM raw_contracts rc
+ WHERE rc.unp = dedup.unp AND rc.contract_number = dedup.contract_number
+ AND rc.signing_value > 0
+ -- #305 multi-annex: value_before may be a prior cumulative total (a preceding annex's
+ -- value_after), not signing. Anchor to signing OR a legitimately-grown prior total (prev not
+ -- itself a double); a single ≥2× step violates ЗОП чл.116 wherever it sits (see normalize-raw.sql).
+ AND (
+ ABS(dedup.value_before - rc.signing_value) < 0.01 * rc.signing_value
+ OR EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = dedup.unp AND prev.contract_number = dedup.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - dedup.value_before) < 0.01 * dedup.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double).
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: EXACT single-step 2× on an ORPHAN base (value_before ties neither signing
+ -- nor any prior annex) — mark the row suspect; never rewrites (see normalize-raw.sql). The
+ -- orphan guard leaves compounding chains untouched.
+ OR (
+ ABS(dedup.value_after - 2 * dedup.value_before) < 0.005 * dedup.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = dedup.unp AND prev.contract_number = dedup.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - dedup.value_before) < 0.01 * dedup.value_before
+ )
+ )
+ )
+ AND COALESCE(NULLIF(dedup.currency, ''), COALESCE(NULLIF(rc.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(rc.currency, ''), 'BGN')
+ )
+ THEN 1 ELSE 0 END
FROM dedup
WHERE rn = 1;
diff --git a/scripts/refresh-slice.sql b/scripts/refresh-slice.sql
index 3f4544be..60854cf5 100644
--- a/scripts/refresh-slice.sql
+++ b/scripts/refresh-slice.sql
@@ -21,13 +21,226 @@ DROP TABLE IF EXISTS refresh_unp_prefix_authorities;
DROP TABLE IF EXISTS refresh_joint_authority_members;
DROP TABLE IF EXISTS refresh_joint_tender_sources;
DROP TABLE IF EXISTS refresh_amendment_winners;
+DROP TABLE IF EXISTS refresh_amendment_contract_resolve;
CREATE TABLE refresh_touched_contracts (id TEXT PRIMARY KEY);
CREATE TABLE refresh_touched_bidders (bidder_id TEXT PRIMARY KEY);
CREATE TABLE refresh_touched_authorities (authority_id TEXT PRIMARY KEY);
+
+-- #286: recover the УНП for OCDS amendments via the tender.id bridge before any raw_amendments read
+-- below, mirroring derive-amendments.sql (raw_tenders first, then the raw_contracts synthetic-tender
+-- fallback). In the slice path the raw tables hold only the loaded window, so an OCDS amendment whose
+-- procedure was staged outside the window stays unbridged until the next full derive — best-effort by
+-- design; the full pipeline is authoritative.
+-- KEEP THE BRIDGE UPDATE BELOW IN LOCKSTEP with scripts/derive-amendments.sql: the UPDATE between the
+-- @bridge-lockstep markers must stay byte-for-byte equivalent to the full path (packages/db/src/
+-- amendments-bridge-lockstep.test.ts enforces it). The prefer-EOP dedup INTENTIONALLY DIVERGES from the
+-- full path — the slice path also reconciles against the cumulative served `amendments` table (below and
+-- before the promotion), which the full path never needs because promote-amendments.sql rebuilds it from
+-- scratch. Index raw_contracts(tender_ext_id) so the fallback lookups don't full-scan raw_contracts per
+-- OCDS row (raw_tenders(tender_id) is already indexed); ORDER BY unp keeps the recovered УНП deterministic.
+CREATE INDEX IF NOT EXISTS idx_raw_contracts_tender_ext_id ON raw_contracts(tender_ext_id);
+-- @bridge-lockstep start
+UPDATE raw_amendments
+SET unp = COALESCE(
+ (SELECT rt.unp FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL ORDER BY rt.unp LIMIT 1),
+ (SELECT rc.unp FROM raw_contracts rc
+ WHERE rc.tender_ext_id = raw_amendments.tender_ext_id AND rc.unp IS NOT NULL ORDER BY rc.unp LIMIT 1)
+)
+WHERE source LIKE 'ocds:%'
+ AND tender_ext_id IS NOT NULL
+ AND (
+ -- raw_tenders wins when it resolves the procedure to exactly ONE УНП; else fall back to raw_contracts
+ -- when IT is unambiguous. Refuse to bridge (leave the OCID as an honest residual) when the chosen
+ -- source maps one tender_ext_id to more than one distinct УНП — the domain is 1-to-1, so this guards a
+ -- feed anomaly rather than silently mis-attributing every annex of the losing procedure (issue #286).
+ (SELECT COUNT(DISTINCT rt.unp) FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL) = 1
+ OR (
+ NOT EXISTS (SELECT 1 FROM raw_tenders rt
+ WHERE rt.tender_id = raw_amendments.tender_ext_id AND rt.unp IS NOT NULL)
+ AND (SELECT COUNT(DISTINCT rc.unp) FROM raw_contracts rc
+ WHERE rc.tender_ext_id = raw_amendments.tender_ext_id AND rc.unp IS NOT NULL) = 1
+ )
+ );
+-- @bridge-lockstep end
+
+-- #306: slice-safe value-anchor resolver. Links EOP annexes whose annex-side number is in a different
+-- namespace than the contract's filing number (annex carries an internal number like 148846; the contract
+-- carries Д-226), by the exact value_before → signing_value anchor — the same 99.99%-precision link the
+-- full path applies in scripts/resolve-amendment-contracts.sql. It runs HERE, before the prefer-EOP dedup
+-- DELETE below and the amendment promotion further down, for the SAME reason the full path runs it before
+-- derive-amendments.sql's dedup: rewriting an EOP annex onto a contract that already kept an OCDS twin would
+-- resurrect the twin and trip amendment-twin-dedup (#303, review todorkolev #1).
+--
+-- The full path is gated OFF the slice because its candidates came from the WINDOWED raw_contracts, where
+-- "unique on the procedure" means "unique in the window" and a corpus-ambiguous annex would mislink (review
+-- nikimilenkov HIGH 1). This slice version closes that gap the way the deferred plan §4 prescribes: candidate
+-- contracts are drawn from the CUMULATIVE served `contracts` (the whole corpus) UNIONed with this window's
+-- raw_contracts, so uniqueness is asked over the corpus, not the window — the measured precision carries. It
+-- is intentionally NOT under a byte-identical lockstep marker with the full-path script: the candidate source
+-- differs by construction. Scans are bounded to the procedures that actually have an EOP annex in this window
+-- (window_unps), so the served-corpus read stays a keyed lookup, not a full scan (idx_contracts_tender_id).
+-- Resolved prior-window targets need no extra touch-wiring: the rewrite below lands the target contract_number
+-- on raw_amendments, and the existing `@refresh-batch amendments` touch join (raw_amendments → contracts on
+-- contract_number) then scopes those targets into refresh_touched_contracts for free (plan §4).
+DROP TABLE IF EXISTS refresh_amendment_contract_resolve;
+CREATE TABLE refresh_amendment_contract_resolve AS
+WITH window_unps AS (
+ SELECT DISTINCT unp FROM raw_amendments
+ WHERE source LIKE 'eop:%' AND unp IS NOT NULL AND contract_number IS NOT NULL
+),
+-- Every contract NUMBER that exists on the affected procedures — from BOTH this window's raw_contracts and the
+-- served corpus, REGARDLESS of signing_value. `grp` below asks this to decide "is the annex's number already a
+-- real contract on the procedure". It MUST be value-agnostic: the full path asks only "does such a contract
+-- exist" (NOT EXISTS over raw_contracts), so keying the slice question off contract_candidates (which requires
+-- signing_value > 0) would diverge — an annex that points to a ZERO-value contract by number would look
+-- namespace-mismatched and get value-linked to a neighbour, contradicting "links by number, not value" and
+-- making the two paths disagree (review todorkolev).
+all_contract_numbers AS (
+ SELECT unp, contract_number FROM raw_contracts
+ WHERE contract_number IS NOT NULL AND unp IN (SELECT unp FROM window_unps)
+ UNION
+ SELECT substr(tender_id, 3) AS unp, contract_number FROM contracts
+ WHERE contract_number IS NOT NULL AND tender_id IN (SELECT 't:' || unp FROM window_unps)
+),
+-- One row per LOGICAL contract on the affected procedures, for VALUE matching (signing_value > 0). Two sources,
+-- deduped: (0) this window's raw_contracts — cumulative EOP buckets repeat a contract across days, so collapse
+-- to the latest source-day then highest id, mirroring normalize-raw; (1) the served `contracts` corpus (unp =
+-- the tender_id suffix, contractor ЕИК via the winning bidder). A contract present in BOTH is one logical
+-- contract — src_rank keeps the window row (freshest signing_value) and the COUNT below never double-counts it
+-- into a false ambiguity.
+contract_candidates AS (
+ SELECT unp, contract_number, signing_value, currency, contractor_eik FROM (
+ SELECT unp, contract_number, signing_value, currency, contractor_eik,
+ ROW_NUMBER() OVER (
+ PARTITION BY unp, contract_number ORDER BY src_rank, cand_source DESC, ord DESC
+ ) AS rn
+ FROM (
+ SELECT c.unp, c.contract_number, c.signing_value, c.currency,
+ TRIM(CASE WHEN c.contractor_eik LIKE 'ЕИК %' THEN SUBSTR(c.contractor_eik, 5) ELSE c.contractor_eik END)
+ AS contractor_eik,
+ 0 AS src_rank, c.source AS cand_source, c.id AS ord
+ FROM raw_contracts c
+ WHERE c.contract_number IS NOT NULL
+ AND c.signing_value IS NOT NULL AND c.signing_value > 0
+ AND c.unp IN (SELECT unp FROM window_unps)
+ UNION ALL
+ SELECT substr(c.tender_id, 3) AS unp, c.contract_number, c.signing_value, c.currency,
+ b.eik_normalized AS contractor_eik,
+ 1 AS src_rank, '' AS cand_source, '' AS ord
+ FROM contracts c
+ LEFT JOIN bidders b ON b.id = c.bidder_id
+ WHERE c.contract_number IS NOT NULL
+ AND c.signing_value IS NOT NULL AND c.signing_value > 0
+ AND c.tender_id IN (SELECT 't:' || unp FROM window_unps)
+ )
+ ) WHERE rn = 1
+),
+-- EOP annexes on the affected procedures that match NO REAL contract by (unp, contract_number) — the
+-- namespace-mismatch group. Value-less members (admin/term steps mid-chain) are included so they can inherit
+-- the chain's target (review nikimilenkov MEDIUM 2). The NOT EXISTS is over all_contract_numbers (every real
+-- contract number on the procedure, value-agnostic), so an annex that DOES match a corpus contract directly is
+-- correctly excluded — it links by number, not value — even when that target has signing_value <= 0, matching
+-- the full path exactly (review todorkolev).
+grp AS (
+ SELECT a.id AS amendment_id, a.unp, a.contract_number AS annex_cnum,
+ a.value_before, a.currency,
+ TRIM(CASE WHEN a.contractor_eik LIKE 'ЕИК %' THEN SUBSTR(a.contractor_eik, 5) ELSE a.contractor_eik END)
+ AS contractor_eik
+ FROM raw_amendments a
+ WHERE a.source LIKE 'eop:%'
+ AND a.unp IS NOT NULL AND a.contract_number IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM all_contract_numbers c
+ WHERE c.unp = a.unp AND c.contract_number = a.contract_number
+ )
+),
+-- Exact, currency- and contractor-matched value anchor (< 0.5 стотинка). Currency must be EXPLICIT on both
+-- sides (no blank-vs-blank agreement via a default). The EIK guard is null-tolerant and refuses a value
+-- collision onto a different contractor's contract for free (review nikimilenkov LOW 1 / MEDIUM 5).
+vmatch AS (
+ SELECT g.amendment_id, g.unp, g.annex_cnum, c.contract_number AS resolved_cnum,
+ COUNT(*) OVER (PARTITION BY g.amendment_id) AS n_match
+ FROM grp g
+ JOIN contract_candidates c
+ ON c.unp = g.unp
+ AND ABS(c.signing_value - g.value_before) < 0.005
+ AND NULLIF(c.currency, '') IS NOT NULL
+ AND NULLIF(g.currency, '') IS NOT NULL
+ AND c.currency = g.currency
+ AND (g.contractor_eik IS NULL OR c.contractor_eik IS NULL OR g.contractor_eik = c.contractor_eik)
+ WHERE g.value_before IS NOT NULL AND g.value_before > 0
+),
+-- A member's OWN unique (n_match = 1) exact match — trustworthy on its own, so it always applies and is NOT
+-- voided when annex-number siblings point elsewhere (a lot-base number shared across contracts; review
+-- nikimilenkov MEDIUM 1). Propagation is withheld on disagreement, never the direct hits.
+direct AS (
+ SELECT amendment_id, unp, annex_cnum, resolved_cnum FROM vmatch WHERE n_match = 1
+),
+-- Propagate one AGREED target across a (unp, annex-number) chain to value-less members, only when the direct
+-- members agree on a single contract (review MEDIUM 2).
+group_target AS (
+ SELECT unp, annex_cnum, MIN(resolved_cnum) AS resolved_cnum
+ FROM direct GROUP BY unp, annex_cnum HAVING COUNT(DISTINCT resolved_cnum) = 1
+)
+-- Link to the own unique match, else the agreed group target — UNLESS the member is itself value-ambiguous
+-- (n_match >= 2), which never links directly or by inheritance (review todorkolev #2).
+SELECT g.amendment_id,
+ COALESCE(
+ (SELECT d.resolved_cnum FROM direct d WHERE d.amendment_id = g.amendment_id),
+ (SELECT gt.resolved_cnum FROM group_target gt WHERE gt.unp = g.unp AND gt.annex_cnum = g.annex_cnum)
+ ) AS resolved_cnum
+FROM grp g
+WHERE NOT EXISTS (
+ SELECT 1 FROM vmatch v WHERE v.amendment_id = g.amendment_id AND v.n_match >= 2
+);
+
+CREATE INDEX idx_refresh_amendment_contract_resolve_id
+ ON refresh_amendment_contract_resolve(amendment_id);
+
+-- Rewrite in place, preserving provenance: keep the annex-side number in contract_number_raw and stamp
+-- link_method so value-linked rows stay enumerable through promotion into served `amendments`. The raw number
+-- also keeps the amendment natural_key stable (see the promotion below and derive/promote on the full path),
+-- so a resolved row never collides with a native annex sharing document_number on the target (MEDIUM 3).
+UPDATE raw_amendments
+SET
+ contract_number_raw = contract_number,
+ link_method = 'value_anchor',
+ contract_number = (
+ SELECT r.resolved_cnum FROM refresh_amendment_contract_resolve r WHERE r.amendment_id = raw_amendments.id
+ )
+WHERE id IN (SELECT amendment_id FROM refresh_amendment_contract_resolve WHERE resolved_cnum IS NOT NULL);
+
+DROP TABLE IF EXISTS refresh_amendment_contract_resolve;
+
+-- #286: prefer the EOP annex — drop OCDS twins so annex_count and the served timeline aren't doubled.
+-- The full path (derive-amendments.sql) only checks raw_amendments because it re-stages the whole corpus
+-- every run. The slice path must ALSO consult the cumulative served `amendments`: an EOP annex promoted by
+-- an EARLIER window is not in this window's raw_amendments, yet its OCDS twin must still be dropped, or the
+-- INSERT OR REPLACE promotion below (keyed by a natural_key that never matches across sources) would leave
+-- both rows and double annex_count (issue #286, review nikimilenkov HIGH 1).
+DELETE FROM raw_amendments
+WHERE source LIKE 'ocds:%'
+ AND (
+ EXISTS (
+ SELECT 1 FROM raw_amendments e
+ WHERE e.source LIKE 'eop:%'
+ AND e.unp = raw_amendments.unp
+ AND e.contract_number = raw_amendments.contract_number
+ )
+ OR EXISTS (
+ SELECT 1 FROM amendments s
+ WHERE s.source LIKE 'eop:%'
+ AND s.unp = raw_amendments.unp
+ AND s.contract_number = raw_amendments.contract_number
+ )
+ );
+
CREATE TABLE refresh_amendment_winners AS
WITH keyed AS (
SELECT *,
- 'am:' || COALESCE(unp, '') || ':' || COALESCE(contract_number, '') || ':' ||
+ 'am:' || COALESCE(unp, '') || ':' || COALESCE(NULLIF(contract_number_raw, ''), contract_number, '') || ':' ||
COALESCE(
NULLIF(document_number, ''), NULLIF(correction_number, ''), NULLIF(seq_no, ''),
'content:' || COALESCE(published_at, '') || ':' ||
@@ -1000,7 +1213,7 @@ FROM (
ELSE q.signing_value * q.fx_rate
END AS signing_value_eur,
CASE
- WHEN q.value_flag IN ('value_suspect', 'annex_suspect') OR q.current_value IS NULL THEN NULL
+ WHEN q.value_flag IN ('value_suspect', 'annex_suspect', 'annex_total_suspect') OR q.current_value IS NULL THEN NULL
WHEN q.current_value_currency = 'EUR' THEN q.current_value
WHEN q.current_value_currency = 'BGN' THEN q.current_value / 1.95583
ELSE q.current_value * q.fx_rate
@@ -1010,11 +1223,13 @@ FROM (
CASE y.value_flag
WHEN 'value_suspect' THEN y.proc_est_native
WHEN 'annex_suspect' THEN COALESCE(y.signing_value, y.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(y.signing_value, y.current_value)
ELSE COALESCE(y.current_value, y.signing_value)
END AS display_native,
CASE y.value_flag
WHEN 'value_suspect' THEN NULL
WHEN 'annex_suspect' THEN COALESCE(y.signing_value, y.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(y.signing_value, y.current_value)
ELSE COALESCE(y.current_value, y.signing_value)
END AS trusted_native,
CASE y.value_flag
@@ -1024,6 +1239,11 @@ FROM (
ELSE COALESCE((SELECT NULLIF(w.currency, '') FROM refresh_amendment_winners w
WHERE w.unp = y.unp AND w.contract_number = y.contract_number), NULLIF(y.currency, ''), 'BGN')
END
+ WHEN 'annex_total_suspect' THEN CASE
+ WHEN y.signing_value IS NOT NULL THEN COALESCE(NULLIF(y.currency, ''), 'BGN')
+ ELSE COALESCE((SELECT NULLIF(w.currency, '') FROM refresh_amendment_winners w
+ WHERE w.unp = y.unp AND w.contract_number = y.contract_number), NULLIF(y.currency, ''), 'BGN')
+ END
ELSE CASE
WHEN y.current_value IS NOT NULL THEN COALESCE((SELECT NULLIF(w.currency, '') FROM refresh_amendment_winners w
WHERE w.unp = y.unp AND w.contract_number = y.contract_number), NULLIF(y.currency, ''), 'BGN')
@@ -1059,7 +1279,19 @@ FROM (
-- Over-valuation + absurd FIRST and repaired to the procedure estimate.
-- value_low is labelled-but-counted (see the amount_eur CASE). Keep in sync with
-- normalize-raw.sql and the EOP block below.
- WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND c.eff_eur > 200 * c.proc_est_eur) THEN 'value_suspect'
+ WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND (c.eff_eur > 200 * c.proc_est_eur
+ -- Dropped decimal point: the value was entered in стотинки, so it lands at almost exactly
+ -- 100x the procedure estimate. Real overruns spread out; this is an isolated cluster with
+ -- nothing between 105x and 200x, so the band is narrow on purpose.
+ OR (c.eff_eur >= 95 * c.proc_est_eur AND c.eff_eur <= 105 * c.proc_est_eur)))
+ -- The same dropped decimal point on a LOT of a multi-lot procedure: 100x the lot's OWN
+ -- estimate, while the ratio to the whole procedure lands wherever the lot's share puts it
+ -- (issue #247 reports one at 89.8x). The own-row estimate alone is unsafe - for framework
+ -- and unit-price procedures it is a UNIT price and a whole call-off legitimately dwarfs it -
+ -- so it is paired with the procedure-level condition that already means "implausibly large
+ -- for this procedure" (>= 10x, the review threshold).
+ OR (c.own_est_eur >= 1000 AND c.proc_est_eur >= 1000 AND c.eff_eur >= 10 * c.proc_est_eur
+ AND c.eff_eur >= 95 * c.own_est_eur AND c.eff_eur <= 105 * c.own_est_eur) THEN 'value_suspect'
-- value_low: zero/negative, OR a tiny signed value (< 1000 EUR) that is also < 5% of the
-- estimate. The < 1000 EUR floor keeps large legitimate framework call-offs OUT.
WHEN COALESCE(c.current_value, c.signing_value) <= 0 THEN 'value_low'
@@ -1106,7 +1338,64 @@ FROM (
)
END
), 0) < 0.05 THEN 'value_low'
- WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND c.current_value / c.signing_value >= 100)) THEN 'annex_suspect'
+ WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND (c.current_value / c.signing_value >= 100
+ -- Mis-keyed annex: a single step jumped ≥10× AND the aggregate ended ≥5× over signing.
+ -- The step alone is NOT enough — some chains have a huge step that a later annex pulls
+ -- back below signing, and flagging those would RAISE the shown value, not repair it.
+ OR (c.current_value / c.signing_value >= 5 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ AND am.value_before > 0 AND am.value_after >= 10 * am.value_before
+ ))))) THEN 'annex_suspect'
+ -- #305 value double-count: a driving annex reports a new TOTAL added to the old instead of
+ -- replacing it, so value_after ≈ 2× the OLD total. ЗОП чл.116 caps a single amendment at +50%,
+ -- so one step cannot legally more than double a contract — the ≥2× single step IS the defect
+ -- signal, wherever it sits in the chain. Scope: value_after in [2×,10×) a base that value_before
+ -- ties to a KNOWN prior total — signing_value OR a preceding annex's value_after (the multi-annex
+ -- case) — same currency. Slow legitimate climbs never reach ≥2× so stay untouched; the ≥10×
+ -- mis-key is #299's annex_suspect above; cross-currency doubles are an FX artefact ('review');
+ -- and the ABS(... - current_value) tie binds this to the annex that DRIVES current_value, so a
+ -- doubled annex later superseded by a correct one is NOT flagged.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ -- #305 Tier-2: skip text-treated annexes (restated total or confirmed-genuine increment).
+ AND am.value_treatment IS NULL
+ -- #305 multi-annex: value_before may be a prior cumulative total (a preceding annex's
+ -- value_after), not signing. Anchor to signing OR a legitimately-grown prior total (prev
+ -- not itself a double); a single ≥2× step violates ЗОП чл.116 wherever it sits (see normalize-raw.sql).
+ AND am.value_before > 0 AND (
+ ABS(am.value_before - c.signing_value) < 0.01 * c.signing_value
+ OR EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double),
+ -- so a compounding chain where every step doubles is left untouched, not restated.
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: an EXACT single-step 2× on an ORPHAN base (value_before ties neither
+ -- signing nor any prior annex) is the ЗОП чл.116 defect signature — flag (→ signing
+ -- fallback, EXCLUDE); never rewrites. The orphan guard leaves compounding chains untouched
+ -- (see normalize-raw.sql).
+ OR (
+ ABS(am.value_after - 2 * am.value_before) < 0.005 * am.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ )
+ )
+ AND am.value_after >= 2 * am.value_before AND am.value_after < 10 * am.value_before
+ -- #305 M2 self-consistency: skip when value_delta is present and a ≉ b + d (model N/A).
+ AND (am.value_delta IS NULL OR ABS(am.value_after - (am.value_before + am.value_delta)) < 0.01 * am.value_after)
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) THEN 'annex_total_suspect'
WHEN c.proc_est_eur > 0 AND c.eff_eur >= 10 * c.proc_est_eur THEN 'review'
ELSE 'ok'
END AS value_flag,
@@ -1162,6 +1451,23 @@ FROM (
LIMIT 1
)
END AS proc_est_eur,
+ -- Own-row (per-lot) estimate in EUR - see the note at the стотинки band below: used ONLY
+ -- there, paired with a procedure-level condition, because for framework and unit-price
+ -- procedures this number is a UNIT price.
+ CASE
+ WHEN c.estimated_value IS NULL THEN NULL
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'EUR' THEN c.estimated_value
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'BGN' THEN c.estimated_value / 1.95583
+ ELSE c.estimated_value * (
+ SELECT f.eur_per_unit
+ FROM fx_rates f
+ WHERE f.base_currency = COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''))
+ AND f.rate_date <= c.contract_date
+ AND f.rate_date >= date(c.contract_date, '-10 days')
+ ORDER BY f.rate_date DESC
+ LIMIT 1
+ )
+ END AS own_est_eur,
t.estimated_value AS proc_est_native
FROM raw_contracts c
JOIN contractor_identity ci
@@ -1276,7 +1582,7 @@ FROM (
ELSE q.signing_value * q.fx_rate
END AS signing_value_eur,
CASE
- WHEN q.value_flag IN ('value_suspect', 'annex_suspect') OR q.current_value IS NULL THEN NULL
+ WHEN q.value_flag IN ('value_suspect', 'annex_suspect', 'annex_total_suspect') OR q.current_value IS NULL THEN NULL
WHEN q.current_value_currency = 'EUR' THEN q.current_value
WHEN q.current_value_currency = 'BGN' THEN q.current_value / 1.95583
ELSE q.current_value * q.fx_rate
@@ -1286,11 +1592,13 @@ FROM (
CASE y.value_flag
WHEN 'value_suspect' THEN y.proc_est_native
WHEN 'annex_suspect' THEN COALESCE(y.signing_value, y.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(y.signing_value, y.current_value)
ELSE COALESCE(y.current_value, y.signing_value)
END AS display_native,
CASE y.value_flag
WHEN 'value_suspect' THEN NULL
WHEN 'annex_suspect' THEN COALESCE(y.signing_value, y.current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(y.signing_value, y.current_value)
ELSE COALESCE(y.current_value, y.signing_value)
END AS trusted_native,
CASE y.value_flag
@@ -1300,6 +1608,11 @@ FROM (
ELSE COALESCE((SELECT NULLIF(w.currency, '') FROM refresh_amendment_winners w
WHERE w.unp = y.unp AND w.contract_number = y.contract_number), NULLIF(y.currency, ''), 'BGN')
END
+ WHEN 'annex_total_suspect' THEN CASE
+ WHEN y.signing_value IS NOT NULL THEN COALESCE(NULLIF(y.currency, ''), 'BGN')
+ ELSE COALESCE((SELECT NULLIF(w.currency, '') FROM refresh_amendment_winners w
+ WHERE w.unp = y.unp AND w.contract_number = y.contract_number), NULLIF(y.currency, ''), 'BGN')
+ END
ELSE CASE
WHEN y.current_value IS NOT NULL THEN COALESCE((SELECT NULLIF(w.currency, '') FROM refresh_amendment_winners w
WHERE w.unp = y.unp AND w.contract_number = y.contract_number), NULLIF(y.currency, ''), 'BGN')
@@ -1339,7 +1652,19 @@ FROM (
-- Over-valuation + absurd FIRST and repaired to the procedure estimate.
-- value_low is labelled-but-counted (see the amount_eur CASE). Keep in sync with
-- normalize-raw.sql and the OCDS block above.
- WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND c.eff_eur > 200 * c.proc_est_eur) THEN 'value_suspect'
+ WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND (c.eff_eur > 200 * c.proc_est_eur
+ -- Dropped decimal point: the value was entered in стотинки, so it lands at almost exactly
+ -- 100x the procedure estimate. Real overruns spread out; this is an isolated cluster with
+ -- nothing between 105x and 200x, so the band is narrow on purpose.
+ OR (c.eff_eur >= 95 * c.proc_est_eur AND c.eff_eur <= 105 * c.proc_est_eur)))
+ -- The same dropped decimal point on a LOT of a multi-lot procedure: 100x the lot's OWN
+ -- estimate, while the ratio to the whole procedure lands wherever the lot's share puts it
+ -- (issue #247 reports one at 89.8x). The own-row estimate alone is unsafe - for framework
+ -- and unit-price procedures it is a UNIT price and a whole call-off legitimately dwarfs it -
+ -- so it is paired with the procedure-level condition that already means "implausibly large
+ -- for this procedure" (>= 10x, the review threshold).
+ OR (c.own_est_eur >= 1000 AND c.proc_est_eur >= 1000 AND c.eff_eur >= 10 * c.proc_est_eur
+ AND c.eff_eur >= 95 * c.own_est_eur AND c.eff_eur <= 105 * c.own_est_eur) THEN 'value_suspect'
-- value_low: zero/negative, OR a tiny signed value (< 1000 EUR) that is also < 5% of the
-- estimate. The < 1000 EUR floor keeps large legitimate framework call-offs OUT.
WHEN COALESCE(c.current_value, c.signing_value) <= 0 THEN 'value_low'
@@ -1386,7 +1711,64 @@ FROM (
)
END
), 0) < 0.05 THEN 'value_low'
- WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND c.current_value / c.signing_value >= 100)) THEN 'annex_suspect'
+ WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND (c.current_value / c.signing_value >= 100
+ -- Mis-keyed annex: a single step jumped ≥10× AND the aggregate ended ≥5× over signing.
+ -- The step alone is NOT enough — some chains have a huge step that a later annex pulls
+ -- back below signing, and flagging those would RAISE the shown value, not repair it.
+ OR (c.current_value / c.signing_value >= 5 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ AND am.value_before > 0 AND am.value_after >= 10 * am.value_before
+ ))))) THEN 'annex_suspect'
+ -- #305 value double-count: a driving annex reports a new TOTAL added to the old instead of
+ -- replacing it, so value_after ≈ 2× the OLD total. ЗОП чл.116 caps a single amendment at +50%,
+ -- so one step cannot legally more than double a contract — the ≥2× single step IS the defect
+ -- signal, wherever it sits in the chain. Scope: value_after in [2×,10×) a base that value_before
+ -- ties to a KNOWN prior total — signing_value OR a preceding annex's value_after (the multi-annex
+ -- case) — same currency. Slow legitimate climbs never reach ≥2× so stay untouched; the ≥10×
+ -- mis-key is #299's annex_suspect above; cross-currency doubles are an FX artefact ('review');
+ -- and the ABS(... - current_value) tie binds this to the annex that DRIVES current_value, so a
+ -- doubled annex later superseded by a correct one is NOT flagged.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND EXISTS (
+ SELECT 1 FROM raw_amendments am
+ WHERE am.unp = c.unp AND am.contract_number = c.contract_number
+ -- #305 Tier-2: skip text-treated annexes (restated total or confirmed-genuine increment).
+ AND am.value_treatment IS NULL
+ -- #305 multi-annex: value_before may be a prior cumulative total (a preceding annex's
+ -- value_after), not signing. Anchor to signing OR a legitimately-grown prior total (prev
+ -- not itself a double); a single ≥2× step violates ЗОП чл.116 wherever it sits (see normalize-raw.sql).
+ AND am.value_before > 0 AND (
+ ABS(am.value_before - c.signing_value) < 0.01 * c.signing_value
+ OR EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double),
+ -- so a compounding chain where every step doubles is left untouched, not restated.
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: an EXACT single-step 2× on an ORPHAN base (value_before ties neither
+ -- signing nor any prior annex) is the ЗОП чл.116 defect signature — flag (→ signing
+ -- fallback, EXCLUDE); never rewrites. The orphan guard leaves compounding chains untouched
+ -- (see normalize-raw.sql).
+ OR (
+ ABS(am.value_after - 2 * am.value_before) < 0.005 * am.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ )
+ )
+ AND am.value_after >= 2 * am.value_before AND am.value_after < 10 * am.value_before
+ -- #305 M2 self-consistency: skip when value_delta is present and a ≉ b + d (model N/A).
+ AND (am.value_delta IS NULL OR ABS(am.value_after - (am.value_before + am.value_delta)) < 0.01 * am.value_after)
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) THEN 'annex_total_suspect'
WHEN c.proc_est_eur > 0 AND c.eff_eur >= 10 * c.proc_est_eur THEN 'review'
ELSE 'ok'
END AS value_flag,
@@ -1442,6 +1824,23 @@ FROM (
LIMIT 1
)
END AS proc_est_eur,
+ -- Own-row (per-lot) estimate in EUR - see the note at the стотинки band below: used ONLY
+ -- there, paired with a procedure-level condition, because for framework and unit-price
+ -- procedures this number is a UNIT price.
+ CASE
+ WHEN c.estimated_value IS NULL THEN NULL
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'EUR' THEN c.estimated_value
+ WHEN COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''), 'BGN') = 'BGN' THEN c.estimated_value / 1.95583
+ ELSE c.estimated_value * (
+ SELECT f.eur_per_unit
+ FROM fx_rates f
+ WHERE f.base_currency = COALESCE(NULLIF(c.procurement_currency, ''), NULLIF(c.currency, ''))
+ AND f.rate_date <= c.contract_date
+ AND f.rate_date >= date(c.contract_date, '-10 days')
+ ORDER BY f.rate_date DESC
+ LIMIT 1
+ )
+ END AS own_est_eur,
t.estimated_value AS proc_est_native
FROM raw_contracts c
JOIN contractor_identity ci
@@ -1503,14 +1902,29 @@ WHERE status <> 'awarded'
-- 5) Promote window amendments into served domain history and roll touched contracts.
-- @refresh-batch amendments
+-- #286 convergence (review nikimilenkov HIGH 1), the other direction of the served-table reconciliation
+-- above: an EOP annex arriving in THIS window supersedes an OCDS twin a PRIOR slice already served for the
+-- same (unp, contract_number). Drop the stale served OCDS row before promotion so the rollup never counts
+-- both. The full path needs no equivalent — promote-amendments.sql rebuilds `amendments` wholesale.
+DELETE FROM amendments
+WHERE source LIKE 'ocds:%'
+ AND EXISTS (
+ SELECT 1 FROM raw_amendments e
+ WHERE e.source LIKE 'eop:%'
+ AND e.unp = amendments.unp
+ AND e.contract_number = amendments.contract_number
+ );
+
INSERT OR REPLACE INTO amendments (
- id, natural_key, contract_number, unp, value_before, value_after, value_delta, currency,
- published_at, document_number, description, source
+ id, natural_key, contract_number, contract_number_raw, link_method, unp,
+ value_before, value_after, value_delta, currency,
+ published_at, document_number, description, source,
+ value_restated, value_treatment, value_suspect
)
WITH keyed AS (
SELECT
*,
- 'am:' || COALESCE(unp, '') || ':' || COALESCE(contract_number, '') || ':' ||
+ 'am:' || COALESCE(unp, '') || ':' || COALESCE(NULLIF(contract_number_raw, ''), contract_number, '') || ':' ||
COALESCE(
NULLIF(document_number, ''),
NULLIF(correction_number, ''),
@@ -1535,15 +1949,66 @@ SELECT
natural_key,
natural_key,
contract_number,
+ contract_number_raw, -- #306 provenance: the annex-side number before the value resolver rewrote it
+ link_method, -- #306 provenance: 'value_anchor' for value-linked rows, else NULL
unp,
value_before,
- value_after,
- value_delta,
+ -- #305 Tier-2: serve the effective (text-corrected) after and a self-consistent delta; the current_value
+ -- rollup below reads this served value_after, so a restated annex drives current_value with the true total.
+ COALESCE(value_after_restated, value_after),
+ COALESCE(value_after_restated, value_after) - value_before,
currency,
published_at,
document_number,
description,
- source
+ source,
+ CASE WHEN value_after_restated IS NOT NULL THEN 1 ELSE 0 END,
+ value_treatment,
+ -- #305 residual: mark a suspected double-count that is NOT already text-treated so the UI suppresses
+ -- the untrusted value_after. Mirrors normalize-raw.sql's annex_total_suspect arithmetic gate, but
+ -- joined to raw_contracts for the contract's signing_value/currency (this served INSERT has no
+ -- contract row to read). value_treatment IS NULL keeps a restated/genuine row out (value_restated
+ -- already owns those). No current_value tie here: the tie in normalize-raw only decides whether the
+ -- CONTRACT is flagged; the per-row marker suppresses any row whose after is an unbridgeable double.
+ CASE WHEN value_treatment IS NULL
+ AND value_before > 0
+ AND value_after >= 2 * value_before AND value_after < 10 * value_before
+ -- #305 M2 self-consistency: skip when value_delta is present and a ≉ b + d (model N/A).
+ AND (value_delta IS NULL OR ABS(value_after - (value_before + value_delta)) < 0.01 * value_after)
+ AND EXISTS (
+ SELECT 1 FROM raw_contracts rc
+ WHERE rc.unp = dedup.unp AND rc.contract_number = dedup.contract_number
+ AND rc.signing_value > 0
+ -- #305 multi-annex: value_before may be a prior cumulative total (a preceding annex's
+ -- value_after), not signing. Anchor to signing OR a legitimately-grown prior total (prev not
+ -- itself a double); a single ≥2× step violates ЗОП чл.116 wherever it sits (see normalize-raw.sql).
+ AND (
+ ABS(dedup.value_before - rc.signing_value) < 0.01 * rc.signing_value
+ OR EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = dedup.unp AND prev.contract_number = dedup.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - dedup.value_before) < 0.01 * dedup.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double).
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: EXACT single-step 2× on an ORPHAN base (value_before ties neither signing
+ -- nor any prior annex) — mark the row suspect; never rewrites (see normalize-raw.sql). The
+ -- orphan guard leaves compounding chains untouched.
+ OR (
+ ABS(dedup.value_after - 2 * dedup.value_before) < 0.005 * dedup.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_amendments prev
+ WHERE prev.unp = dedup.unp AND prev.contract_number = dedup.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - dedup.value_before) < 0.01 * dedup.value_before
+ )
+ )
+ )
+ AND COALESCE(NULLIF(dedup.currency, ''), COALESCE(NULLIF(rc.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(rc.currency, ''), 'BGN')
+ )
+ THEN 1 ELSE 0 END
FROM dedup
WHERE rn = 1;
@@ -1559,7 +2024,10 @@ SET
WHERE a.unp = substr(contracts.tender_id, 3)
AND a.contract_number = contracts.contract_number
AND a.value_after IS NOT NULL
- ORDER BY a.published_at DESC, a.id DESC
+ -- #305: tie-break on natural_key to match the full-rebuild path (derive-amendments.sql), so the
+ -- driving amendment picked for current_value is identical across full and slice when two annexes
+ -- share published_at. `id` (row insertion order) diverged from natural_key and could pick a different row.
+ ORDER BY a.published_at DESC, a.natural_key DESC
LIMIT 1
),
current_value_currency = (
@@ -1567,7 +2035,9 @@ SET
WHERE a.unp = substr(contracts.tender_id, 3)
AND a.contract_number = contracts.contract_number
AND a.value_after IS NOT NULL
- ORDER BY a.published_at DESC, a.id DESC
+ -- #305: same natural_key tie-break as current_value above, so the currency comes from the same
+ -- driving amendment the value does.
+ ORDER BY a.published_at DESC, a.natural_key DESC
LIMIT 1
)
WHERE (id GLOB 'c:[eo]:*' AND EXISTS (
@@ -1626,7 +2096,131 @@ WITH contract_base AS (
)
ORDER BY rc.source DESC, rc.id DESC
LIMIT 1
- ), te.estimated_value) AS classifier_estimated_value
+ ), te.estimated_value) AS classifier_estimated_value,
+ c.signed_at,
+ -- The contract row's OWN estimate and the currency it is denominated in, for the стотинки band's
+ -- own-row arm (#247). Deliberately WITHOUT the procedure fallback classifier_estimated_value carries:
+ -- the arm asks "is this 100× the row's own estimate", and a row with no own estimate has no answer.
+ -- Falling back would make the arm fire exactly where the procedure band already fires, i.e. mean
+ -- something other than its name. The four INSERT sites read raw_contracts.estimated_value the same way.
+ (
+ SELECT rc.estimated_value
+ FROM raw_contracts rc
+ WHERE rc.unp = substr(c.tender_id, 3)
+ AND rc.contract_number = c.contract_number
+ AND (
+ (c.id LIKE 'c:e:%' AND rc.source LIKE 'eop:%')
+ OR (c.id LIKE 'c:o:%' AND rc.source LIKE 'ocds:%')
+ )
+ ORDER BY rc.source DESC, rc.id DESC
+ LIMIT 1
+ ) AS own_est_native,
+ -- Same row, same order — the estimate's OWN currency chain, byte-for-byte the one the four INSERT
+ -- sites use (procurement_currency → currency → BGN). Reading it off contracts.currency instead would
+ -- convert a foreign-currency estimate at the contract's currency (review cefothe #4).
+ (
+ SELECT COALESCE(NULLIF(rc.procurement_currency, ''), NULLIF(rc.currency, ''), 'BGN')
+ FROM raw_contracts rc
+ WHERE rc.unp = substr(c.tender_id, 3)
+ AND rc.contract_number = c.contract_number
+ AND (
+ (c.id LIKE 'c:e:%' AND rc.source LIKE 'eop:%')
+ OR (c.id LIKE 'c:o:%' AND rc.source LIKE 'ocds:%')
+ )
+ ORDER BY rc.source DESC, rc.id DESC
+ LIMIT 1
+ ) AS own_est_currency,
+ -- One annex step jumped ≥10× — half of the mis-keyed-annex conjunction below. Checked against
+ -- the CUMULATIVE domain amendments, matching where this pass re-rolls current_value from.
+ EXISTS (
+ SELECT 1 FROM amendments am
+ WHERE am.unp = substr(c.tender_id, 3)
+ AND am.contract_number = c.contract_number
+ AND am.value_before > 0 AND am.value_after >= 10 * am.value_before
+ ) AS has_step10,
+ -- #305 value double-count: a driving annex whose value_before ≈ a KNOWN prior total — the contract's
+ -- signing_value OR a preceding annex's value_after (the multi-annex case) — with value_after in
+ -- [2×,10×) that base, same currency, and matching current_value. Checked against the CUMULATIVE domain
+ -- amendments, matching where this pass re-rolls current_value from. ЗОП чл.116 caps a single amendment
+ -- at +50%, so the ≥2× step is the defect signal wherever it sits; slow climbs never reach ≥2×, and the
+ -- ≥10× mis-key and cross-currency cases are handled elsewhere.
+ EXISTS (
+ SELECT 1 FROM amendments am
+ WHERE am.unp = substr(c.tender_id, 3)
+ AND am.contract_number = c.contract_number
+ -- #305 Tier-2: skip text-treated annexes. Restated totals already stop matching (served value_after
+ -- is the corrected total, no longer ≈2× before), but the explicit guard also covers confirmed-genuine
+ -- increments, whose value_after is legitimately ≥2× and must NOT be arithmetic-flagged.
+ AND am.value_treatment IS NULL
+ AND am.value_before > 0 AND c.signing_value > 0
+ -- #305 multi-annex: value_before may be a prior cumulative total (a preceding annex's
+ -- value_after), not signing — anchor to signing OR a legitimately-grown prior total, prev not
+ -- itself a double (see normalize-raw.sql).
+ AND (
+ ABS(am.value_before - c.signing_value) < 0.01 * c.signing_value
+ OR EXISTS (
+ SELECT 1 FROM amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ -- #305 NEW-HIGH-2: this reconciliation reads the CUMULATIVE served `amendments` (a prior-window
+ -- annex is not in this window's raw_amendments), but the full path anchors on RAW values. For a
+ -- #305-restated prev the served value_after is the CORRECTED (lower) total, not the raw one, so
+ -- `value_after < 2*value_before` flips true and the gate would disagree with the full rebuild
+ -- (flag flips between the daily slice and the next full derive). Restrict the anchor to
+ -- non-restated prevs, whose served value_after == raw value_after — reproducing the full-path
+ -- (raw) decision without losing cross-window history.
+ AND prev.value_restated = 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ -- ...and that prior total was itself reached legitimately (prev not a ≥2× double),
+ -- so a compounding chain where every step doubles is left untouched, not restated.
+ AND prev.value_before > 0 AND prev.value_after < 2 * prev.value_before
+ )
+ -- #305 84818-class: an EXACT single-step 2× on an ORPHAN base (value_before ties neither signing
+ -- nor any prior served annex) is the ЗОП чл.116 defect signature — flag (→ signing fallback,
+ -- EXCLUDE); never rewrites. The orphan guard leaves compounding chains untouched (see
+ -- normalize-raw.sql).
+ OR (
+ ABS(am.value_after - 2 * am.value_before) < 0.005 * am.value_before
+ AND NOT EXISTS (
+ SELECT 1 FROM amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_after > 0
+ AND ABS(prev.value_after - am.value_before) < 0.01 * am.value_before
+ )
+ )
+ )
+ AND am.value_after >= 2 * am.value_before AND am.value_after < 10 * am.value_before
+ -- #305 M2 self-consistency: skip when value_delta is present and a ≉ b + d (model N/A).
+ AND (am.value_delta IS NULL OR ABS(am.value_after - (am.value_before + am.value_delta)) < 0.01 * am.value_after)
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) AS has_double,
+ -- #305 NEW-HIGH-1 (multi-annex chain contamination), slice mirror. The full path (normalize-raw.sql)
+ -- detects this on RAW values (prev.value_after_restated < prev.value_after AND am.value_before ≈ raw
+ -- prev.value_after). The slice reads the CUMULATIVE served `amendments`, which does NOT retain the raw
+ -- value_after of a restated prev — so this is a CONSERVATIVE approximation: a driving annex whose
+ -- value_before sits ABOVE a restated prior annex's CORRECTED total (it rode the raw, doubled base) but
+ -- within a contamination band (< 2× the corrected total, i.e. not a fresh legitimate double). Flags →
+ -- signing fallback, matching the full path's honest exclusion. Exactness is restored on the next full
+ -- rebuild; a follow-up value_before-propagation PR removes the approximation entirely.
+ EXISTS (
+ SELECT 1 FROM amendments am
+ WHERE am.unp = substr(c.tender_id, 3)
+ AND am.contract_number = c.contract_number
+ AND am.value_treatment IS NULL
+ AND am.value_before > 0
+ AND ABS(am.value_after - c.current_value) < 0.01
+ AND EXISTS (
+ SELECT 1 FROM amendments prev
+ WHERE prev.unp = am.unp AND prev.contract_number = am.contract_number
+ AND prev.value_restated = 1
+ AND am.value_before > prev.value_after
+ AND am.value_before < 2 * prev.value_after
+ )
+ AND COALESCE(NULLIF(am.currency, ''), COALESCE(NULLIF(c.currency, ''), 'BGN'))
+ = COALESCE(NULLIF(c.currency, ''), 'BGN')
+ ) AS has_contaminated_base
FROM contracts c
JOIN tenders te ON te.id = c.tender_id
WHERE (
@@ -1649,25 +2243,65 @@ WITH contract_base AS (
), base AS (
SELECT id, currency, signing_value, current_value, current_value_currency, fx_rate, proc_est_eur, proc_est_native,
CASE
- WHEN c.value_flag <> 'annex_suspect'
- AND NOT (c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND c.current_value / c.signing_value >= 100)))
+ WHEN c.value_flag NOT IN ('annex_suspect', 'annex_total_suspect')
+ AND NOT (c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND (c.current_value / c.signing_value >= 100
+ OR (c.current_value / c.signing_value >= 5 AND c.has_step10)))))
+ AND NOT (c.current_value IS NOT NULL AND c.signing_value > 0 AND (c.has_double OR c.has_contaminated_base))
THEN c.value_flag
- WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND c.eff_eur > 200 * c.proc_est_eur) THEN 'value_suspect'
- WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND c.current_value / c.signing_value >= 100)) THEN 'annex_suspect'
+ WHEN c.eff_eur > 2000000000 OR (c.proc_est_eur >= 1000 AND (c.eff_eur > 200 * c.proc_est_eur
+ -- Dropped decimal point: the value was entered in стотинки, so it lands at almost exactly
+ -- 100x the procedure estimate. Real overruns spread out; this is an isolated cluster with
+ -- nothing between 105x and 200x, so the band is narrow on purpose.
+ OR (c.eff_eur >= 95 * c.proc_est_eur AND c.eff_eur <= 105 * c.proc_est_eur)))
+ -- Own-row (per-lot) arm, mirroring the two INSERT-time copies above.
+ OR (c.own_est_eur >= 1000 AND c.proc_est_eur >= 1000 AND c.eff_eur >= 10 * c.proc_est_eur
+ AND c.eff_eur >= 95 * c.own_est_eur AND c.eff_eur <= 105 * c.own_est_eur) THEN 'value_suspect'
+ WHEN c.current_value IS NOT NULL AND (c.current_value < 0 OR (c.signing_value > 0 AND (c.current_value / c.signing_value >= 100
+ -- Mis-keyed annex: a single step jumped ≥10× AND the aggregate ended ≥5× over signing.
+ -- The step alone is NOT enough — some chains have a huge step that a later annex pulls
+ -- back below signing, and flagging those would RAISE the shown value, not repair it.
+ OR (c.current_value / c.signing_value >= 5 AND c.has_step10)))) THEN 'annex_suspect'
+ -- #305 single-annex value double-count: the driving annex more than doubled the contract in one
+ -- step (ЗОП чл.116 caps a single amendment at +50%). has_double already ties to current_value.
+ -- has_contaminated_base additionally catches a legitimate-looking later annex riding a doubled base
+ -- (#305 NEW-HIGH-1) — both fall back to signing.
+ WHEN c.current_value IS NOT NULL AND c.signing_value > 0 AND (c.has_double OR c.has_contaminated_base) THEN 'annex_total_suspect'
WHEN c.proc_est_eur > 0 AND c.eff_eur >= 10 * c.proc_est_eur THEN 'review'
ELSE 'ok'
END AS new_value_flag
- FROM contract_base c
+ FROM (
+ -- Per-lot estimate in EUR for the стотинки band's own-row arm (see the note at the band). Mirrors
+ -- the four INSERT sites: the row's OWN estimate (NULL when it has none), converted through the
+ -- estimate's own currency with a dated fx lookup — not through the contract's currency or fx_rate.
+ SELECT cb.*,
+ CASE
+ WHEN cb.own_est_native IS NULL THEN NULL
+ WHEN cb.own_est_currency = 'EUR' THEN cb.own_est_native
+ WHEN cb.own_est_currency = 'BGN' THEN cb.own_est_native / 1.95583
+ ELSE cb.own_est_native * (
+ SELECT f.eur_per_unit
+ FROM fx_rates f
+ WHERE f.base_currency = cb.own_est_currency
+ AND f.rate_date <= cb.signed_at
+ AND f.rate_date >= date(cb.signed_at, '-10 days')
+ ORDER BY f.rate_date DESC
+ LIMIT 1
+ )
+ END AS own_est_eur
+ FROM contract_base cb
+ ) c
), calc AS (
SELECT id, new_value_flag, proc_est_eur,
CASE new_value_flag
WHEN 'value_suspect' THEN proc_est_native
WHEN 'annex_suspect' THEN COALESCE(signing_value, current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(signing_value, current_value)
ELSE COALESCE(current_value, signing_value)
END AS display_native,
CASE new_value_flag
WHEN 'value_suspect' THEN NULL
WHEN 'annex_suspect' THEN COALESCE(signing_value, current_value)
+ WHEN 'annex_total_suspect' THEN COALESCE(signing_value, current_value)
ELSE COALESCE(current_value, signing_value)
END AS trusted_native,
CASE new_value_flag
@@ -1676,13 +2310,17 @@ WITH contract_base AS (
WHEN signing_value IS NOT NULL THEN COALESCE(NULLIF(currency, ''), 'BGN')
ELSE COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN')
END
+ WHEN 'annex_total_suspect' THEN CASE
+ WHEN signing_value IS NOT NULL THEN COALESCE(NULLIF(currency, ''), 'BGN')
+ ELSE COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN')
+ END
ELSE CASE
WHEN current_value IS NOT NULL THEN COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN')
ELSE COALESCE(NULLIF(currency, ''), 'BGN')
END
END AS trusted_currency,
CASE
- WHEN new_value_flag IN ('value_suspect', 'annex_suspect') OR current_value IS NULL THEN NULL
+ WHEN new_value_flag IN ('value_suspect', 'annex_suspect', 'annex_total_suspect') OR current_value IS NULL THEN NULL
WHEN COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN') = 'EUR' THEN current_value
WHEN COALESCE(NULLIF(current_value_currency, ''), NULLIF(currency, ''), 'BGN') = 'BGN' THEN current_value / 1.95583
WHEN fx_rate IS NOT NULL THEN current_value * fx_rate
@@ -1863,6 +2501,13 @@ FROM interest_links il JOIN persons p ON p.id = il.person_id
-- rendering both re-identifies the relative via a ТР owner lookup, and the company is already surfaced
-- by the self row.
WHERE il.status = 'published' AND il.interest_class IN ('private_ownership', 'family_ownership')
+ -- …and the identity rests on a Trade Register fact (#279, ADR-0033). This predicate is the THIRD copy
+ -- of the surface gate — the other two are SURFACED_OWNERSHIP in packages/db/src/queries/related-persons.ts
+ -- and the sibling block in the other of precompute.sql / refresh-slice.sql. All three must move
+ -- together: this one feeds the officials search index, so omitting it would keep officials findable
+ -- whose links no longer surface.
+ AND EXISTS (SELECT 1 FROM interest_link_evidence e
+ WHERE e.link_key = il.link_key AND e.evidence_kind IN ('document','confirmed'))
AND EXISTS (SELECT 1 FROM contracts cc JOIN bidders bb ON bb.id = cc.bidder_id
WHERE bb.eik_normalized = il.eik)
AND NOT (il.interest_class = 'family_ownership' AND EXISTS (
diff --git a/scripts/resolve-amendment-contracts.sql b/scripts/resolve-amendment-contracts.sql
new file mode 100644
index 00000000..3498505d
--- /dev/null
+++ b/scripts/resolve-amendment-contracts.sql
@@ -0,0 +1,150 @@
+-- Sigma — #306: link EOP annexes whose annex-side number is in a different namespace than the contract
+-- number. The annex carries an internal number (e.g. 148846) while the contract on the same procedure
+-- carries the buyer's filing number (e.g. Д-226), so the (unp, contract_number) join drops ~7% of EOP
+-- annexes out of every annex→contract→company/authority rollup. String normalisation recovers almost none
+-- of them (measured), because the two numbers are genuinely unrelated identifiers. Resolve by VALUE
+-- instead: an annex's value_before is the contract's value at amendment time, so it equals the target
+-- contract's signing_value. Link only when value_before matches EXACTLY (< 0.5 стотинка), currency- and
+-- contractor-matched, uniquely one contract on the procedure — measured 99.99% precision on the already-
+-- linked corpus (9348/9349). Rows that match 2+ contracts (value-ambiguous) or none (target not yet
+-- ingested — the #249 class) are LEFT unlinked: an honest gap beats a wrong contract on a transparency site.
+--
+-- FULL-PATH FORM (review nikimilenkov HIGH 1 + todorkolev #3). This file runs from runFullDerive /
+-- runWorkBackfill in scripts/import.mjs, BEFORE derive-amendments.sql, with candidates drawn from the whole
+-- re-staged raw_contracts. It is deliberately NOT run on the slice path (runSliceDerive): the slice's
+-- raw_contracts holds only the current window, so "unique on the procedure" would mean "unique in the window",
+-- not in the corpus — a corpus-ambiguous annex would look unique in a narrow window and mislink. The daily/
+-- slice + Worker path instead runs a corpus-safe equivalent INSIDE scripts/refresh-slice.sql (search "#306:
+-- slice-safe value-anchor resolver"), whose candidates come from the served `contracts` table plus this
+-- window's raw_contracts, so uniqueness is asked corpus-wide and the measured precision carries. Both forms
+-- share the same value/currency/EIK anchor, chain rules, and provenance stamping; see
+-- docs/implementation-plans/306-amendment-contract-namespace-link.md §4.
+--
+-- ORDER (review todorkolev #1, the blocker): this runs BEFORE the #286 prefer-EOP dedup DELETE in
+-- derive-amendments.sql. Rewriting an EOP annex onto a contract that already kept an OCDS twin would
+-- resurrect that twin (annex_count = 2 on a one-annex contract) and trip the amendment-twin-dedup integrity
+-- gate (#303), failing the whole derive. Running first — and above the #286 diagnostics, so their dropped/
+-- excess counts stay honest bounds on what the dedup actually removes — keeps the dedup the sole twin guard.
+-- The resolver reads only source LIKE 'eop:%' rows + raw_contracts, so it has no dependency on the OCDS
+-- bridge and is safe to run first.
+
+-- Candidate contracts, deduped to one row per LOGICAL contract. raw_contracts is CUMULATIVE — the EOP daily
+-- open-data buckets repeat the same contract across consecutive days, and the collapse to one row per
+-- (unp, contract_number) happens later in normalize-raw.sql, NOT in staging (review nikimilenkov HIGH 2).
+-- Without this dedup, COUNT(*) OVER below would count staging ROWS, not contracts: a contract present in N
+-- daily buckets would read as n_match = N and fail-close every real link, and a superseded row could match
+-- an annex to a stale value. Mirror normalize-raw's rule (latest source-day, then highest id, wins).
+DROP TABLE IF EXISTS amendment_contract_resolve;
+CREATE TABLE amendment_contract_resolve AS
+WITH contract_candidates AS (
+ SELECT unp, contract_number, signing_value, currency, contractor_eik
+ FROM (
+ SELECT c.unp, c.contract_number, c.signing_value, c.currency, c.contractor_eik,
+ ROW_NUMBER() OVER (
+ PARTITION BY c.unp, c.contract_number
+ ORDER BY c.source DESC, c.id DESC
+ ) AS rn
+ FROM raw_contracts c
+ WHERE c.contract_number IS NOT NULL
+ AND c.signing_value IS NOT NULL AND c.signing_value > 0
+ )
+ WHERE rn = 1
+),
+-- Every EOP annex whose (unp, contract_number) matches no contract — the namespace-mismatch group. Includes
+-- value-less members (value_before NULL/≤0): an admin/term annex mid-chain carries no signing value of its
+-- own but must still inherit the chain's target (review nikimilenkov MEDIUM 2), else the chain breaks and
+-- current_value stops early. Grouped by the shared annex-side number (unp, annex_cnum) = one contract's chain.
+grp AS (
+ SELECT a.id AS amendment_id, a.unp, a.contract_number AS annex_cnum,
+ a.value_before, a.currency, a.contractor_eik
+ FROM raw_amendments a
+ WHERE a.source LIKE 'eop:%'
+ AND a.unp IS NOT NULL AND a.contract_number IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM raw_contracts c
+ WHERE c.unp = a.unp AND c.contract_number = a.contract_number
+ )
+),
+-- Exact, currency- and contractor-matched value anchor. Only members that carry a usable value_before
+-- participate in matching. The currency guard requires an EXPLICIT currency on both sides (review
+-- nikimilenkov LOW 1): a blank-vs-blank pair must not silently agree via a 'BGN' default under so tight a
+-- gate. The EIK guard (review nikimilenkov MEDIUM 5) is null-tolerant — the annex already carries the
+-- contractor, so a value collision onto a DIFFERENT contractor's contract is refused for free.
+vmatch AS (
+ SELECT g.amendment_id, g.unp, g.annex_cnum, c.contract_number AS resolved_cnum,
+ COUNT(*) OVER (PARTITION BY g.amendment_id) AS n_match
+ FROM grp g
+ JOIN contract_candidates c
+ ON c.unp = g.unp
+ AND ABS(c.signing_value - g.value_before) < 0.005
+ AND NULLIF(c.currency, '') IS NOT NULL
+ AND NULLIF(g.currency, '') IS NOT NULL
+ AND c.currency = g.currency
+ AND (g.contractor_eik IS NULL OR c.contractor_eik IS NULL OR g.contractor_eik = c.contractor_eik)
+ WHERE g.value_before IS NOT NULL AND g.value_before > 0
+),
+-- `direct` — each member's OWN unique (n_match = 1) exact value match. This is trustworthy on its own
+-- (exact cent + unique on the whole procedure = the measured 99.99% precision), so a direct hit always
+-- applies. Crucially it is NOT voided when its annex-number siblings point elsewhere: an annex-side number
+-- can be a LOT-BASE shared across contracts (real corpus: `20РП-У50А015` → …-Л01 @ 22569.98 AND …-Л03 @
+-- 28557.50, each annex exactly-uniquely matching its own lot). The one disagreement group in the whole live
+-- corpus is exactly this benign multi-lot case — refusing it (an earlier revision, review nikimilenkov
+-- MEDIUM 1) dropped 2 confirmed-correct links and prevented zero wrong ones, so disagreement withholds
+-- PROPAGATION only, never the direct hits themselves.
+direct AS (
+ SELECT amendment_id, unp, annex_cnum, resolved_cnum FROM vmatch WHERE n_match = 1
+),
+-- `group_target` — propagate ONE agreed target across a `(unp, annex-number)` chain to members that have no
+-- own unique match (value-less admin steps, or later steps whose cumulative value matches no signing_value),
+-- but only when the direct members AGREE (a lot-base spread disagrees → no propagation, and the direct hits
+-- still stand). So later chain steps link and `current_value` reflects the last step (review MEDIUM 2).
+group_target AS (
+ SELECT unp, annex_cnum, MIN(resolved_cnum) AS resolved_cnum
+ FROM direct GROUP BY unp, annex_cnum HAVING COUNT(DISTINCT resolved_cnum) = 1
+)
+-- A member links to its OWN unique match if it has one; else to the agreed group target — UNLESS the member
+-- is itself value-ambiguous (n_match >= 2), which carries its own contradicting evidence and never links,
+-- directly or by inheritance (review todorkolev #2). Members with neither a direct hit nor an agreed group
+-- target keep resolved_cnum NULL and stay unlinked.
+SELECT g.amendment_id,
+ COALESCE(
+ (SELECT d.resolved_cnum FROM direct d WHERE d.amendment_id = g.amendment_id),
+ (SELECT gt.resolved_cnum FROM group_target gt WHERE gt.unp = g.unp AND gt.annex_cnum = g.annex_cnum)
+ ) AS resolved_cnum
+FROM grp g
+WHERE NOT EXISTS (
+ SELECT 1 FROM vmatch v WHERE v.amendment_id = g.amendment_id AND v.n_match >= 2
+);
+
+-- The rewrite UPDATE below correlates raw_amendments.id to amendment_contract_resolve.amendment_id once per
+-- unlinked row; index the resolve table so that is a lookup, not a scan (review nikimilenkov LOW 4).
+CREATE INDEX IF NOT EXISTS idx_amendment_contract_resolve_id
+ ON amendment_contract_resolve(amendment_id);
+
+-- Rewrite contract_number in place — exactly like the #286 УНП bridge — and PRESERVE PROVENANCE (review
+-- nikimilenkov MEDIUM 4): keep the original annex-side number in contract_number_raw and stamp link_method
+-- so the value-linked rows stay enumerable in staging, through promote, and in the served `amendments`
+-- table. contract_number_raw also keeps the annex number in the amendment natural_key (see derive-
+-- amendments.sql / promote-amendments.sql), so a resolved row never collides with a native annex that
+-- happens to share document_number on the target contract (review nikimilenkov MEDIUM 3).
+UPDATE raw_amendments
+SET
+ contract_number_raw = contract_number,
+ link_method = 'value_anchor',
+ contract_number = (
+ SELECT r.resolved_cnum FROM amendment_contract_resolve r WHERE r.amendment_id = raw_amendments.id
+ )
+WHERE id IN (SELECT amendment_id FROM amendment_contract_resolve WHERE resolved_cnum IS NOT NULL);
+
+-- #306 diagnostic (printed by wrangler): annexes linked by the value anchor, and those still unlinked. The
+-- two predicates are complementary — both count over the SAME namespace-mismatch population (review
+-- nikimilenkov LOW 3) — so linked + still_unlinked = the original mismatch count on a healthy run.
+SELECT
+ (SELECT COUNT(*) FROM raw_amendments WHERE link_method = 'value_anchor') AS annexes_value_linked,
+ (SELECT COUNT(*) FROM raw_amendments a
+ WHERE a.source LIKE 'eop:%' AND a.unp IS NOT NULL AND a.contract_number IS NOT NULL
+ AND a.link_method IS NULL
+ AND NOT EXISTS (SELECT 1 FROM raw_contracts c
+ WHERE c.unp = a.unp AND c.contract_number = a.contract_number)) AS eop_annexes_still_unlinked;
+
+DROP TABLE IF EXISTS amendment_contract_resolve;
diff --git a/scripts/seed.sql b/scripts/seed.sql
index 2ff25add..3d26a0da 100644
--- a/scripts/seed.sql
+++ b/scripts/seed.sql
@@ -16,3 +16,103 @@ INSERT OR IGNORE INTO bidders (id, name, bulstat) VALUES
('bidder-a', 'Алфа ЕООД', '111111111'),
('bidder-b', 'Бета АД', '222222222'),
('bidder-c', 'Гама ООД', '333333333');
+
+-- ── свързани лица (conflict-of-interest) smoke fixture ────────────────────────────────────────────
+-- Without these the /conflicts routes render an empty surface locally, so the whole feature is
+-- unverifiable in a browser. Every person and company below is INVENTED — no real declarant, no real
+-- winner. The ЕИК pass the real 9-digit checksum (scripts/normalize-raw.sql's eik_valid), so the
+-- fixture cannot teach a wrong ЕИК shape to anyone reading it.
+--
+-- The rows are shaped by what the read gate actually requires (packages/db/src/queries/related-persons.ts):
+-- • status='published' AND interest_class IN (private_ownership, family_ownership) — SURFACED_OWNERSHIP
+-- • a LIVE contract for that ЕИК — the read-time zero-contract gate
+-- • signed_at inside [first_declared_year, last_declared_year] — IN_WINDOW ⇒ the „в периода" chip
+-- • no published SELF stake on the same (official, ЕИК) for a family row — NOT_REDUNDANT_FAMILY
+-- • a Trade Register evidence seal of a PUBLISHING rung — the #279 seal gate
+-- One link per outcome, including the non-surfaced ones, so a change to the publishing rule is
+-- visible as a before/after rather than as an empty page either way.
+
+INSERT OR IGNORE INTO authorities (id, name, bulstat, region) VALUES
+ ('auth-plovdiv', 'Община Пример', '131223340', 'Пловдив');
+
+INSERT OR IGNORE INTO tenders
+ (id, source_id, title, authority_id, cpv_code, estimated_value, currency, procedure_type, status, published_at, deadline_at)
+VALUES
+ ('t-conflict-01', 'AOP-2022-0101', 'Строителен надзор на общински обекти', 'auth-plovdiv', '71520000', 900000, 'BGN', 'открита процедура', 'closed', '2022-01-10', '2022-02-10'),
+ ('t-conflict-02', 'AOP-2022-0102', 'Консултантски услуги по проект', 'auth-sofia', '79400000', 400000, 'BGN', 'открита процедура', 'closed', '2022-04-05', '2022-05-05'),
+ ('t-conflict-03', 'AOP-2022-0103', 'Доставка на офис оборудване', 'auth-mrrb', '30190000', 250000, 'BGN', 'открита процедура', 'closed', '2022-06-01', '2022-07-01');
+
+-- eik_valid=1 so these behave like resolved winners; settlement feeds the seat-confirmation rung.
+INSERT OR IGNORE INTO bidders (id, name, bulstat, eik_normalized, eik_valid, legal_form, settlement) VALUES
+ ('eik:201122335', 'АЛФА СТРОЙ ООД', '201122335', '201122335', 1, 'ООД', 'Пловдив'),
+ ('eik:203445566', 'БЕТА КОНСУЛТ ЕООД', '203445566', '203445566', 1, 'ЕООД', 'София'),
+ ('eik:204556676', 'ГАМА ИНВЕСТ АД', '204556676', '204556676', 1, 'АД', 'Варна');
+
+INSERT OR IGNORE INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, contract_number, amount_eur) VALUES
+ ('c-conflict-01', 't-conflict-01', 'eik:201122335', 840000, 'BGN', '2022-03-14', 'Д-2022-101', 429000),
+ ('c-conflict-02', 't-conflict-02', 'eik:203445566', 372000, 'BGN', '2022-06-20', 'Д-2022-102', 190000),
+ ('c-conflict-03', 't-conflict-03', 'eik:204556676', 236000, 'BGN', '2022-08-02', 'Д-2022-103', 120000);
+
+-- person id = 'person:' || key(name) || '|' || key(institution) — (name, institution), never a bare
+-- name (ADR-0026), so two namesakes at different bodies stay distinct.
+INSERT OR IGNORE INTO persons (id, name) VALUES
+ ('person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР', 'Иван Петров Тестов'),
+ ('person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ', 'Мария Георгиева Образцова');
+
+INSERT OR IGNORE INTO declarations
+ (id, person_id, xml_file, control_hash, folder_year, declared_year, template, category, institution, position, source_url)
+VALUES
+ ('decl:2023:demo-ivan.xml', 'person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР', 'demo-ivan.xml', 'demo-hash-ivan', '2023', '2023', 'assets', 'Местна власт', 'Община Пример', 'Кмет', 'https://register.cacbg.bg/2023/demo-ivan.xml'),
+ ('decl:2023:demo-maria.xml', 'person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ', 'demo-maria.xml', 'demo-hash-maria', '2023', '2023', 'assets', 'Местна власт', 'Община София', 'Директор дирекция', 'https://register.cacbg.bg/2023/demo-maria.xml');
+
+INSERT OR IGNORE INTO declared_interests (id, declaration_id, entity_raw, entity_key, kind, detail, timing, seat) VALUES
+ ('di:decl:2023:demo-ivan.xml:1', 'decl:2023:demo-ivan.xml', 'АЛФА СТРОЙ ООД', 'АЛФА СТРОЙ ООД', 'shares', '50%', 'annual', 'Пловдив'),
+ ('di:decl:2023:demo-ivan.xml:2', 'decl:2023:demo-ivan.xml', 'ГАМА ИНВЕСТ АД', 'ГАМА ИНВЕСТ АД', 'shares', '0,5%', 'annual', 'Варна'),
+ ('di:decl:2023:demo-maria.xml:1', 'decl:2023:demo-maria.xml', 'БЕТА КОНСУЛТ ЕООД', 'БЕТА КОНСУЛТ ЕООД', 'shares', '100%', 'annual', 'София');
+
+-- Three links, three outcomes:
+-- published private_ownership — the official's own stake, own_institution='exact' (the strongest signal)
+-- published family_ownership — a relative's declared stake; the relative is never named (ADR-0032)
+-- held (АД, C_hold) — a joint-stock parcel: collected, never surfaced (ADR-0022 materiality)
+INSERT OR IGNORE INTO interest_links
+ (id, link_key, person_id, bidder_id, eik, entity_key, match_method, matcher_version, publish_tier,
+ relation, interest_class, contemporaneous, own_institution, evidence_count,
+ first_declared_year, last_declared_year, contract_count, contract_value_eur,
+ first_contract_year, last_contract_year, status)
+VALUES
+ ('il:person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|201122335', 'person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|201122335',
+ 'person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР', 'eik:201122335', '201122335', 'АЛФА СТРОЙ ООД',
+ 'exact_name_key', 'seed-demo', 'B_distinctive', 'owns', 'private_ownership', 1, 'exact', 1,
+ '2021', '2023', 1, 429000, '2022', '2022', 'published'),
+ ('il:person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ|203445566|family', 'person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ|203445566|family',
+ 'person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ', 'eik:203445566', '203445566', 'БЕТА КОНСУЛТ ЕООД',
+ 'exact_name_key', 'seed-demo', 'A_seat', 'related', 'family_ownership', 1, 'exact', 1,
+ '2021', '2023', 1, 190000, '2022', '2022', 'published'),
+ ('il:person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|204556676', 'person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|204556676',
+ 'person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР', 'eik:204556676', '204556676', 'ГАМА ИНВЕСТ АД',
+ 'exact_name_key', 'seed-demo', 'C_hold', 'owns', 'management_role', 0, 'none', 1,
+ '2021', '2023', 1, 120000, '2022', '2022', 'held');
+
+INSERT OR IGNORE INTO interest_link_authorities (link_key, authority_id, authority_name, contract_count, value_eur, own) VALUES
+ ('person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|201122335', 'auth-plovdiv', 'Община Пример', 1, 429000, 'exact'),
+ ('person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ|203445566|family', 'auth-sofia', 'Община София', 1, 190000, 'exact');
+
+-- Trade Register evidence seals (#279, ADR-0033, migration 0006). NOT optional decoration: since the
+-- evidence ladder landed, SURFACED_OWNERSHIP requires a publishing seal, so a seeded link without one
+-- renders NOTHING — which is the exact failure this fixture exists to prevent. A seal per link, one per
+-- rung, so the dev surface shows the same three outcomes as production:
+-- document — the register names this person in this company (the strongest rung)
+-- confirmed — identity confirmed by declared data (seat), no person found in the act itself
+-- bar_joint_stock — an АД: a declared parcel of shares is not a material ownership conflict
+-- matched_fact stays inside the closed vocabulary (evidence.mjs isSealedFact) — never a name.
+INSERT OR IGNORE INTO interest_link_evidence
+ (link_key, evidence_kind, registry_role, matched_fact, entry_number, entry_date, lookup_date, rules_version, live_status)
+VALUES
+ ('person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|201122335', 'document', 'owner', 'role:owner:CR_F_19_L',
+ '20220314150210', '2022-03-14', '2026-08-05', 'tr-rules-1', 'live'),
+ ('person:МАРИЯ ГЕОРГИЕВА ОБРАЗЦОВА|ОБЩИНА СОФИЯ|203445566|family', 'confirmed', NULL, 'seat:СОФИЯ',
+ '20210902110455', '2021-09-02', '2026-08-05', 'tr-rules-1', 'live'),
+ -- The held link is sealed too — seals exist for held links so the review queue can explain itself,
+ -- and this one doubles as the fixture's negative case for the seal gate.
+ ('person:ИВАН ПЕТРОВ ТЕСТОВ|ОБЩИНА ПРИМЕР|204556676', 'bar_joint_stock', NULL, NULL,
+ NULL, NULL, '2026-08-05', 'tr-rules-1', 'live');
diff --git a/scripts/ship-e2e.test.mjs b/scripts/ship-e2e.test.mjs
new file mode 100644
index 00000000..1d150914
--- /dev/null
+++ b/scripts/ship-e2e.test.mjs
@@ -0,0 +1,379 @@
+// End-to-end coverage of the SHIP PATH ITSELF — the one thing unit tests of the helpers cannot give.
+//
+// Three times a refactor of this script silently dropped a guarantee while the suite stayed green:
+// when main() called applyTableChunks, again after that moved into runShip(), and again when a first
+// cut of THIS file asserted only on request filenames and counts — so mutations that shipped an empty
+// payload, a wipe that deleted nothing, or every request to a PRODUCTION slot all passed.
+//
+// The fix is to stop trusting a stub: the fake `wrangler` here APPLIES each --file to a real sqlite
+// target and answers each --command FROM that target. The assertions are then about what the target
+// actually holds, which no amount of correct-looking request plumbing can fake.
+//
+// Run: node --test scripts/ship-e2e.test.mjs
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync, spawnSync } from 'node:child_process';
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ MAX_BATCH_ROWS,
+ MAX_STATEMENTS_PER_REQUEST,
+ PACE_MS,
+ TABLES,
+} from './ship-related-persons.mjs';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ROOT = resolve(HERE, '..');
+const SCRIPT = resolve(HERE, 'ship-related-persons.mjs');
+const MIG = resolve(ROOT, 'packages/db/migrations/0003_related_persons_foundation.sql');
+// 0006 too: the evidence seal is one of the shipped tables (#279, ADR-0033), so it appears in the ship's
+// TABLES and WIPE_ORDER. A target built from 0003 alone makes the generated wipe abort on „no such table"
+// before a single row moves — the schema under test has to be the schema the ship writes.
+const MIG_EVIDENCE = resolve(ROOT, 'packages/db/migrations/0009_interest_link_evidence.sql');
+const D1_NAME = 'sigma-test-local';
+
+// Derived from BOTH production constants, and the run below does NOT override either: one row past
+// what a single default request can carry, so chunking is exercised at the shipped settings. Forcing
+// it with --max-statements-per-request=1 (the first cut of this file) left the real constant free to
+// be retuned to infinity with the suite still green.
+const LINKS = MAX_STATEMENTS_PER_REQUEST * MAX_BATCH_ROWS + 1;
+const EXPECTED_CHUNKS = Math.ceil(LINKS / MAX_BATCH_ROWS / MAX_STATEMENTS_PER_REQUEST);
+// The failure-mode tests below do not need the full-size corpus — they force chunking with a flag and
+// keep the fixture small, so only the one test that constrains the defaults pays for 10k rows.
+const LINKS_SMALL = MAX_BATCH_ROWS * 2 + 1;
+
+const sqlite = (db, input) => execFileSync('sqlite3', ['-bail', db], { input, stdio: 'pipe' });
+
+// Unlike the other scripts/*.test.mjs this one needs the `sqlite3` BINARY (present on ubuntu-latest
+// and in the devcontainer): the fake wrangler applies real SQL to a real database, which is the whole
+// reason these assertions mean anything. Say so up front — a missing binary would otherwise surface as
+// an opaque ENOENT from whichever test happened to run first. Failing, never skipping: a skip here
+// silently returns the suite to the state where mutations walked through it.
+if (spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).error) {
+ throw new Error('scripts/ship-e2e.test.mjs requires the sqlite3 binary on PATH');
+}
+
+/** The served свързани-лица tables plus the two FK parents they reference. */
+const SCHEMA = `PRAGMA foreign_keys=ON;
+CREATE TABLE bidders(id TEXT PRIMARY KEY);
+CREATE TABLE authorities(id TEXT PRIMARY KEY);
+.read ${MIG}
+.read ${MIG_EVIDENCE}
+INSERT INTO bidders(id) VALUES('eik:1');
+INSERT INTO authorities(id) VALUES('auth:1');`;
+
+const corpus = (links) => `
+INSERT INTO persons(id,name) VALUES('p1','П Тест');
+INSERT INTO declarations(id,person_id,xml_file,folder_year,template,source_url) VALUES('d1','p1','x.xml','2024','assets','u');
+INSERT INTO declared_interests(id,declaration_id,entity_raw,entity_key,kind) VALUES('di1','d1','E','e','shares');
+${Array.from(
+ { length: links },
+ (_, i) =>
+ `INSERT INTO interest_links(id,link_key,person_id,bidder_id,eik,entity_key,matcher_version,publish_tier,relation,status) VALUES('il${i}','p1|${i}','p1','eik:1','1','e','v1','B_distinctive','owns','published');\n` +
+ // One seal per link. Since #279 a published link without one is exactly the state the audit fails
+ // the run on (C_no_evidence) and the read gate refuses to surface, so a corpus without seals is not
+ // a shape the ship should ever be asked to carry.
+ `INSERT INTO interest_link_evidence(link_key,evidence_kind,lookup_date,rules_version,live_status) VALUES('p1|${i}','document','2026-08-13','tr-rules-1','live');`,
+).join('\n')}
+`;
+// interest_link_authorities is deliberately left EMPTY above: its expected count is 0, which is the
+// only shape where `Number(null) === 0` would let an unanswered read-back pass for "table is empty".
+
+const STALE = `
+INSERT INTO persons(id,name) VALUES('stale','Стар запис');
+INSERT INTO declarations(id,person_id,xml_file,folder_year,template,source_url) VALUES('sd','stale','s.xml','2019','assets','u');
+INSERT INTO declared_interests(id,declaration_id,entity_raw,entity_key,kind) VALUES('sdi','sd','S','s','shares');
+INSERT INTO interest_links(id,link_key,person_id,bidder_id,eik,entity_key,matcher_version,publish_tier,relation,status) VALUES('sil','stale|0','stale','eik:1','1','s','v0','B_distinctive','owns','published');
+INSERT INTO interest_link_authorities(link_key,authority_id,authority_name) VALUES('stale|0','auth:1','A');
+INSERT INTO interest_link_evidence(link_key,evidence_kind,lookup_date,rules_version,live_status) VALUES('stale|0','confirmed','2019-01-01','tr-rules-0','live');`;
+
+const EXPECTED_ROWS = {
+ persons: 1,
+ declarations: 1,
+ declared_interests: 1,
+ interest_links: LINKS,
+ interest_link_evidence: LINKS, // one seal per link (#279)
+ interest_link_authorities: 0,
+};
+
+/**
+ * A `wrangler` that touches no network but is otherwise faithful: it records the FULL argv, applies
+ * every --file to the target sqlite DB with foreign keys ON (so a wrong wipe order fails exactly as
+ * D1 would), and answers every --command from that same DB. Notices go to stderr and pure JSON to
+ * stdout, mirroring real `wrangler --json`.
+ *
+ * SHIP_FAKE_SKIP — drop one --file by name, to simulate a request that never landed.
+ * SHIP_FAKE_NULLN — answer the read-back with a non-numeric count, to exercise the fail-closed guard.
+ * SHIP_FAKE_NOISE — emit a `[WARNING]`-shaped line on stdout before the JSON.
+ * SHIP_FAKE_READFAIL — make the read-back call itself fail, so the catch path is exercised.
+ */
+function fakeWrangler(dir) {
+ const bin = join(dir, 'bin');
+ mkdirSync(bin, { recursive: true });
+ const log = join(dir, 'calls.jsonl');
+ const target = join(dir, 'target.sqlite');
+ // Seeded with STALE rows on purpose: against an empty target a wipe that deletes nothing is
+ // indistinguishable from a correct one, and that mutation escaped the first cut of this test.
+ sqlite(target, SCHEMA + STALE);
+ const exe = join(bin, 'wrangler');
+ writeFileSync(
+ exe,
+ `#!/usr/bin/env node
+import { appendFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+const argv = process.argv.slice(2);
+const at = (f) => (argv.indexOf(f) >= 0 ? argv[argv.indexOf(f) + 1] : null);
+const file = at('--file');
+const command = at('--command');
+const TARGET = ${JSON.stringify(target)};
+appendFileSync(${JSON.stringify(log)}, JSON.stringify({ argv, file: file && file.split('/').pop() }) + '\\n');
+const run = (input) =>
+ execFileSync('sqlite3', ['-bail', TARGET], { input: 'PRAGMA foreign_keys=ON;\\n' + input, encoding: 'utf8' });
+try {
+ if (file) {
+ const skip = process.env.SHIP_FAKE_SKIP;
+ if (!skip || !file.endsWith(skip)) run('.read ' + file);
+ } else if (command) {
+ if (process.env.SHIP_FAKE_READFAIL) { process.stderr.write('read-back exploded'); process.exit(1); }
+ const rows = JSON.parse(run('.mode json\\n' + command) || '[]');
+ const shaped = process.env.SHIP_FAKE_NULLN
+ ? rows.map((r) => (r.t === process.env.SHIP_FAKE_NULLN ? { ...r, n: null } : r))
+ : rows;
+ if (process.env.SHIP_FAKE_NOISE) process.stdout.write('▲ [WARNING] Processing wrangler.jsonc\\n');
+ process.stdout.write(JSON.stringify([{ results: shaped, success: true }]));
+ }
+} catch (err) {
+ process.stderr.write(String(err.stderr || err.message));
+ process.exit(1);
+}
+`,
+ { mode: 0o755 },
+ );
+ chmodSync(exe, 0o755);
+ // package.json so the extensionless fake is unambiguously ESM wherever os.tmpdir() lives.
+ writeFileSync(join(bin, 'package.json'), '{"type":"module"}');
+ return {
+ bin,
+ target,
+ calls: () =>
+ readFileSync(log, 'utf8')
+ .trim()
+ .split('\n')
+ .filter(Boolean)
+ .map((l) => JSON.parse(l)),
+ count: (t) =>
+ Number(execFileSync('sqlite3', [target, `SELECT COUNT(*) FROM ${t};`]).toString()),
+ };
+}
+
+function runShip(
+ dir,
+ {
+ env = {},
+ links = LINKS_SMALL,
+ forceChunks = true,
+ minLinks = 1,
+ remote = false,
+ yes = false,
+ emit = null,
+ } = {},
+) {
+ const work = join(dir, 'work.sqlite');
+ sqlite(work, SCHEMA + corpus(links));
+ const fake = fakeWrangler(dir);
+ const res = spawnSync(
+ process.execPath,
+ [
+ SCRIPT,
+ `--work-db=${work}`,
+ ...(remote ? ['--remote'] : ['--local']),
+ ...(yes ? ['--yes'] : []),
+ ...(emit ? [`--emit=${emit}`] : []),
+ `--min-links=${minLinks}`,
+ // The pacing delay is always zeroed to keep the suite quick — it is covered by the runShip unit
+ // tests. Whether the REQUEST SIZE is overridden matters: the defaults-constraining test leaves
+ // it alone on purpose.
+ '--pace-ms=0',
+ ...(forceChunks ? ['--max-statements-per-request=1'] : []),
+ ],
+ {
+ cwd: ROOT,
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ...env,
+ SIGMA_D1_NAME: D1_NAME,
+ PATH: `${fake.bin}:${process.env.PATH}`,
+ },
+ },
+ );
+ return { res, fake };
+}
+
+test('a real ship run leaves the target holding exactly what the work DB held', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ const { res, fake } = runShip(dir, { links: LINKS, forceChunks: false });
+ assert.equal(res.status, 0, `ship failed:\n${res.stderr}`);
+
+ // The assertion that no amount of correct-looking plumbing can fake: the SQL really applied.
+ for (const [table, n] of Object.entries({ ...EXPECTED_ROWS, interest_links: LINKS }))
+ assert.equal(fake.count(table), n, `${table} did not land`);
+
+ const calls = fake.calls();
+ // Every request must name the declared DB and stay local — a mutation that retargets a production
+ // slot is the single highest-consequence regression this script can suffer.
+ for (const c of calls) {
+ assert.deepEqual(c.argv.slice(0, 3), ['d1', 'execute', D1_NAME]);
+ assert.ok(c.argv.includes('--local'), `request escaped --local: ${c.argv.join(' ')}`);
+ assert.ok(!c.argv.includes('--remote'), `request went remote: ${c.argv.join(' ')}`);
+ }
+
+ const applies = calls.filter((c) => c.file);
+ assert.match(applies[0].file, /^0_wipe\./, 'the wipe must be the first request');
+
+ // Chunking: a table past the batch budget must arrive as several CONTIGUOUSLY numbered requests.
+ const nums = applies
+ .map((c) => /^interest_links\.(\d+)\./.exec(c.file))
+ .filter(Boolean)
+ .map((m) => Number(m[1]));
+ assert.ok(
+ nums.length >= 2,
+ `interest_links must be chunked, got ${JSON.stringify(applies.map((c) => c.file))}`,
+ );
+ assert.equal(nums.length, EXPECTED_CHUNKS);
+ assert.deepEqual(
+ nums,
+ Array.from({ length: nums.length }, (_, i) => i + 1),
+ );
+
+ // The read-back must be the LAST thing the run does, and must count each table from that table.
+ const last = calls.at(-1);
+ assert.ok(last.argv.includes('--command'), 'the read-back must come after the inserts');
+ const sql = last.argv[last.argv.indexOf('--command') + 1];
+ for (const table of TABLES)
+ assert.match(
+ sql,
+ new RegExp(`COUNT\\(\\*\\) AS n FROM "${table}"`),
+ `${table} not really counted`,
+ );
+});
+
+test('a request that never landed fails the run', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-short-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ // The skip targets a LEAF table on purpose. Since #279 the ship also carries interest_link_evidence,
+ // which has an FK to interest_links — so dropping an interest_links chunk now fails on the FOREIGN KEY
+ // as the orphaned seals land, before the read-back ever runs. That is a stronger guard, but it would
+ // leave the read-back gate itself unexercised, which is what this test is for. A seal chunk has no
+ // dependents, so its loss is invisible until the counts are compared.
+ const { res } = runShip(dir, { env: { SHIP_FAKE_SKIP: 'interest_link_evidence.2.sql' } });
+ assert.notEqual(res.status, 0, 'a short target must fail the run');
+ assert.match(res.stderr, /ship verification FAILED/);
+ assert.match(res.stderr, /interest_link_evidence: shipped \d+, target has \d+/);
+});
+
+test('a read-back that answers with a non-number fails closed', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-nan-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ // `Number(null)` is 0, which would read as "the table is empty" and quietly pass.
+ const { res } = runShip(dir, { env: { SHIP_FAKE_NULLN: 'interest_link_authorities' } });
+ assert.notEqual(res.status, 0, 'an unanswered count must fail the run');
+ assert.match(res.stderr, /ship verification FAILED/);
+});
+
+test('a bracketed notice on stdout does not corrupt the read-back', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-noise-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ // This is a HYPOTHETICAL, and saying so is the point. `wrangler d1 execute --json` was run against
+ // the real tool afterwards: the notice goes to stderr, stdout is clean JSON, and execFileSync
+ // returns stdout alone. So this models a stream layout wrangler does not currently produce.
+ //
+ // It stays as a cheap guard against a future release moving notices onto stdout — but the PR that
+ // added it billed the scan as fixing an observed failure, which it never was. A fake is a claim
+ // about the world; this one went unchecked, the suite was green, and the false claim shipped.
+ // Anything modelled here that has not been confirmed against the real binary gets labelled as such.
+ const { res } = runShip(dir, { env: { SHIP_FAKE_NOISE: '1' } });
+ assert.equal(res.status, 0, `a stdout notice broke the read-back:\n${res.stderr}`);
+});
+
+// The guards below all sit at UNPROTECTED call sites: deleting each one, or moving it after the
+// destructive run, left the whole suite green. They are the last thing standing between a mistyped
+// flag and a wiped production surface, so each gets an end-to-end test that also proves NO request
+// was issued before the refusal.
+// `d1 info` (the id resolution the authorization guard itself needs) is a read and is fine; what must
+// never happen before a refusal is an `execute`, which is what carries the wipe.
+const noWrites = (fake) => {
+ try {
+ return !fake.calls().some((c) => c.argv[0] === 'd1' && c.argv[1] === 'execute');
+ } catch {
+ return true; // the log file is only created by the first invocation
+ }
+};
+
+test('an under-floor corpus refuses to wipe, before any request', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-floor-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ const { res, fake } = runShip(dir, { minLinks: LINKS_SMALL + 1 });
+ assert.notEqual(res.status, 0);
+ assert.match(res.stderr, /refusing to ship/i);
+ assert.ok(noWrites(fake), 'the refusal must come before the wipe');
+});
+
+test('a bare --remote refuses without --yes, before any request', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-remote-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ const { res, fake } = runShip(dir, { remote: true });
+ assert.notEqual(res.status, 0);
+ assert.match(res.stderr, /--remote requires --yes/);
+ assert.ok(noWrites(fake), 'the refusal must come before the wipe');
+});
+
+test('a --remote ship with no declared environment refuses, before any request', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-env-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ const { res, fake } = runShip(dir, { remote: true, yes: true, env: { SIGMA_SHIP_ENV: '' } });
+ assert.notEqual(res.status, 0);
+ assert.match(res.stderr, /SIGMA_SHIP_ENV/);
+ assert.ok(noWrites(fake), 'the refusal must come before the wipe');
+});
+
+test('a read-back that cannot answer at all fails the run', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-readfail-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ // „a verification step that cannot verify must not pass" — the catch returning {} is what makes
+ // that true, and returning the expectation instead would silently pass.
+ const { res } = runShip(dir, { env: { SHIP_FAKE_READFAIL: '1' } });
+ assert.notEqual(res.status, 0);
+ assert.match(res.stderr, /ship verification FAILED/);
+ assert.match(res.stderr, /no answer/);
+});
+
+test('--emit writes a guarded wipe plus one file per table, and touches no database', (t) => {
+ const dir = mkdtempSync(join(tmpdir(), 'ship-e2e-emit-'));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+
+ const out = join(dir, 'emitted');
+ const { res, fake } = runShip(dir, { emit: out });
+ assert.equal(res.status, 0, `emit failed:\n${res.stderr}`);
+ assert.ok(noWrites(fake), '--emit must not touch a database');
+
+ const wipe = readFileSync(join(out, '0_wipe.sql'), 'utf8');
+ assert.match(wipe, /DESTRUCTIVE, UNGUARDED/, 'the emitted wipe must carry its warning header');
+ for (const table of TABLES) assert.match(wipe, new RegExp(`DELETE FROM "${table}"`));
+ for (const table of TABLES) {
+ const body = readFileSync(join(out, `${table}.sql`), 'utf8');
+ if (table === 'interest_link_authorities') assert.equal(body, '', 'empty table, empty file');
+ else assert.match(body, new RegExp(`INSERT INTO "${table}"`));
+ }
+});
diff --git a/scripts/ship-related-persons.mjs b/scripts/ship-related-persons.mjs
index 54da33de..543ce2b5 100644
--- a/scripts/ship-related-persons.mjs
+++ b/scripts/ship-related-persons.mjs
@@ -28,6 +28,8 @@ export const TABLES = [
'declarations',
'declared_interests',
'interest_links',
+ // AFTER interest_links: a seal references its link, so inserting it first fails the FK (#279).
+ 'interest_link_evidence',
'interest_link_authorities',
];
// DELETE order for the pre-insert wipe — children before parents. related_persons_internal (PII, never
@@ -35,6 +37,8 @@ export const TABLES = [
// carrying internal rows would block DELETE FROM declarations.
export const WIPE_ORDER = [
'interest_link_authorities',
+ // BEFORE interest_links, for the mirror reason: deleting a link whose seal survives fails the FK.
+ 'interest_link_evidence',
'related_persons_internal',
'interest_links',
'declared_interests',
@@ -45,7 +49,81 @@ export function wipeSql() {
return WIPE_ORDER.map((t) => `DELETE FROM ${sqlIdent(t)};`).join('\n') + '\n';
}
const MAX_BATCH_BYTES = 90_000;
-const MAX_BATCH_ROWS = 400;
+export const MAX_BATCH_ROWS = 400;
+
+// One `d1 execute --file` per TABLE meant the whole table went up as a single bulk import: on the first
+// full-corpus ship that was 516k rows written in one hour with a p90 batch time of 18.2s, two orders of
+// magnitude above a normal cron hour (2.9-4.5k rows, 19-450ms) — after which the database returned
+// „internal error" for hours, including on its own metadata endpoint, before recovering with all data
+// intact. The Cloudflare-side mechanism is not provable from outside, so this is a deliberate defensive
+// bound rather than a proven fix: cap how much one request carries and leave a gap between requests, so a
+// re-seed is a series of ordinary writes instead of one shock. 25 × MAX_BATCH_ROWS = 10 000 rows/request.
+export const MAX_STATEMENTS_PER_REQUEST = 25;
+export const PACE_MS = 500;
+
+/** Group per-table INSERT statements into request-sized chunks. Pure — unit-tested. */
+export function chunkStatements(statements, maxPerRequest = MAX_STATEMENTS_PER_REQUEST) {
+ if (!Number.isInteger(maxPerRequest) || maxPerRequest < 1)
+ throw new Error(
+ `maxPerRequest must be a positive integer, got ${JSON.stringify(maxPerRequest)}`,
+ );
+ const chunks = [];
+ for (let i = 0; i < statements.length; i += maxPerRequest)
+ chunks.push(statements.slice(i, i + maxPerRequest));
+ return chunks;
+}
+
+/** Block the (synchronous) ship loop without burning CPU. */
+const sleepSync = (ms) => {
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+};
+
+/**
+ * The whole destructive live path: wipe, then paced request-sized inserts per table, then the read-back
+ * check. Both guarantees live HERE, behind injected I/O (`apply`, `sleep`, `readCounts`), because both are
+ * one refactor away from silently vanishing — „one request per table" is exactly the shape this drifts back
+ * to, and a `if (!emit) assert…` line at a call site is exactly the kind of line that gets dropped.
+ * Testing the pure helpers alone did NOT catch either: reverting the call site and deleting the
+ * verification each left the whole suite green. Keep the orchestration itself covered.
+ * @returns {Record} rows shipped per table
+ */
+export function runShip({
+ tables,
+ readTable,
+ wipeSql,
+ apply,
+ sleep,
+ readCounts,
+ maxStatements,
+ paceMs,
+}) {
+ // ONE counter for the whole run, not one per table. Pacing per table left every table boundary
+ // unpaced — including wipe → first insert, which is the single most destructive transition here.
+ let requests = 0;
+ const applyPaced = (label, sql) => {
+ if (requests++) sleep(paceMs); // between requests only — never before the first
+ apply(label, sql);
+ };
+
+ applyPaced('0_wipe', wipeSql);
+
+ const summary = {};
+ for (const table of tables) {
+ const read = readTable(table);
+ if (!read) {
+ summary[table] = 'absent (skipped)';
+ continue;
+ }
+ summary[table] = read.rowCount;
+ const chunks = chunkStatements(read.statements, maxStatements);
+ chunks.forEach((chunk, i) =>
+ applyPaced(chunks.length > 1 ? `${table}.${i + 1}` : table, chunk.join('')),
+ );
+ }
+
+ assertShippedCounts(summary, readCounts(summary));
+ return summary;
+}
// Supports --name=value, --name value, and bare --name (boolean). A --name whose next token is another
// --flag (or absent) is a boolean; otherwise it consumes the next token as its value.
@@ -107,6 +185,17 @@ export function parseMinLinks(raw) {
return n;
}
+/** Shared shape check for the pacing flags: a bare `--flag` must not silently mean 1 (or 0). */
+function parseIntFlag(raw, name, min) {
+ if (raw === true) throw new Error(`--${name} requires a value, e.g. --${name}=25`);
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n < min)
+ throw new Error(`--${name} must be an integer >= ${min}, got ${JSON.stringify(raw)}.`);
+ return n;
+}
+const parsePositiveInt = (raw, name) => parseIntFlag(raw, name, 1);
+const parseNonNegativeInt = (raw, name) => parseIntFlag(raw, name, 0);
+
/**
* The D1 name to ship to. A --remote write MUST name its target explicitly: this path DELETEs every
* свързани-лица table before re-inserting, so a silent fallback on a remote run is unacceptable — an unset
@@ -187,6 +276,56 @@ export function assertD1TargetAuthorized({ remote, shipEnv, d1Name, expectedId,
/** Live lookup of the uuid Cloudflare maps `d1Name` to, via `wrangler d1 info --json`. Returns '' on any
* failure (unknown name, network, parse) so assertD1TargetConsistent turns that into an explicit refusal. */
+/**
+ * Read the shipped tables' row counts back off the target, in one query. Returns {} when the read itself
+ * fails — `assertShippedCounts` then reports every table as unanswered and fails the run, which is the
+ * right way round: a verification step that cannot verify must not pass.
+ */
+/** First bracket that actually parses — a notice on the same stream could contain one of its own.
+ * Belt-and-braces: today wrangler keeps notices on stderr (see the call site). Exported for the test. */
+export function parseWranglerJson(out) {
+ for (let i = out.indexOf('['); i >= 0; i = out.indexOf('[', i + 1)) {
+ try {
+ return JSON.parse(out.slice(i));
+ } catch {
+ // not the payload — keep looking
+ }
+ }
+ return JSON.parse(out); // no usable bracket: let the original parse error surface
+}
+
+function readShippedCounts(d1Name, remote, expected) {
+ const tables = Object.entries(expected)
+ .filter(([, n]) => typeof n === 'number')
+ .map(([t]) => t);
+ if (!tables.length) return {};
+ const sql = tables
+ .map((t) => `SELECT ${sqlLiteral(t)} AS t, COUNT(*) AS n FROM ${sqlIdent(t)}`)
+ .join(' UNION ALL ');
+ try {
+ const out = execFileSync(
+ 'wrangler',
+ ['d1', 'execute', d1Name, remote ? '--remote' : '--local', '--json', '--command', sql],
+ { cwd: resolve('apps/web'), encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 },
+ );
+ // Defensive, NOT a fix for an observed failure — the earlier wording here claimed otherwise and
+ // was wrong. Checked against the real tool: `wrangler d1 execute --json` writes its notices
+ // („▲ [WARNING] Processing wrangler.jsonc") to STDERR and leaves stdout as clean JSON, and
+ // execFileSync returns stdout alone, so slicing from the first '[' is in fact safe today. The
+ // scan below survives a future release that changes that, and costs one failed parse if it does.
+ const parsed = parseWranglerJson(out);
+ const rows = (Array.isArray(parsed) ? parsed[0]?.results : parsed?.results) ?? [];
+ // Only a real number counts as an answer. `Number(null)` is 0, which would let a null-valued cell pass
+ // for „the table is empty"; anything non-numeric must land as NaN so assertShippedCounts fails closed.
+ return Object.fromEntries(rows.map((r) => [r.t, typeof r.n === 'number' ? r.n : Number.NaN]));
+ } catch (err) {
+ console.error(
+ `ship: could not read back row counts — ${err instanceof Error ? err.message : err}`,
+ );
+ return {};
+ }
+}
+
function resolveD1Id(d1Name) {
try {
const out = execFileSync('wrangler', ['d1', 'info', d1Name, '--json'], {
@@ -200,6 +339,34 @@ function resolveD1Id(d1Name) {
}
}
+/**
+ * Compare what we meant to ship against what the target actually holds. Pure — unit-tested; the live read
+ * is injected as `readCounts`.
+ *
+ * The ship is a wipe followed by SEVERAL independent requests (no cross-request transaction), so a failure
+ * part-way leaves the target holding some tables and not others — and today nothing notices: the run exits 0
+ * and the surface renders as a smaller corpus. That asymmetry is glaring next to the READ path, where the
+ * EOP hydrate already refuses to proceed on a local↔remote row-count mismatch. Close it on the destructive
+ * side too: any drift fails the run, loudly and with the numbers.
+ */
+export function assertShippedCounts(expected, actual) {
+ const drift = Object.entries(expected)
+ .filter(([, n]) => typeof n === 'number')
+ .map(([table, n]) => ({ table, expected: n, actual: actual[table] }))
+ .filter(({ expected: e, actual: a }) => a !== e);
+ if (drift.length)
+ throw new Error(
+ 'ship verification FAILED — the target does not hold what was shipped:\n' +
+ drift
+ .map(
+ ({ table, expected: e, actual: a }) =>
+ ` ${table}: shipped ${e}, target has ${a === undefined ? 'no answer' : a}`,
+ )
+ .join('\n') +
+ '\nThe wipe already ran, so the surface is now partial. Re-run the ship.',
+ );
+}
+
/** Batched multi-row INSERTs for one table, bounded by D1's statement size. Pure — unit-tested. */
export function insertStatements(table, cols, rows) {
if (!cols.length || !rows.length) return [];
@@ -238,6 +405,11 @@ function main() {
const remote = Boolean(arg('remote', false));
const d1Name = resolveD1Name({ remote, envName: process.env.SIGMA_D1_NAME });
const minLinks = parseMinLinks(arg('min-links', 50));
+ const maxStatements = parsePositiveInt(
+ arg('max-statements-per-request', MAX_STATEMENTS_PER_REQUEST),
+ 'max-statements-per-request',
+ );
+ const paceMs = parseNonNegativeInt(arg('pace-ms', PACE_MS), 'pace-ms');
if (remote && !arg('yes', false))
throw new Error('--remote requires --yes (guards against an accidental prod write)');
@@ -298,26 +470,46 @@ function main() {
'-- ⚠ DESTRUCTIVE, UNGUARDED: this wipe was emitted with --emit and did NOT pass the live D1\n' +
'-- target-authorization check (SIGMA_SHIP_ENV allowlist + name↔SIGMA_D1_ID). If you apply it by hand,\n' +
'-- YOU are responsible for confirming the target D1 is the intended one before running it.\n';
- if (emit) writeFileSync(resolve(emit, '0_wipe.sql'), EMIT_WIPE_HEADER + wipeSql());
- else applyFile('0_wipe', wipeSql());
+ // One read of a source table: null when the table is absent from the work DB.
+ const readTable = (table) => {
+ const cols = sqliteJson(`PRAGMA table_info(${sqlIdent(table)})`).map((r) => r.name);
+ if (!cols.length) return null;
+ const rows = sqliteJson(`SELECT * FROM ${sqlIdent(table)}`);
+ return { rowCount: rows.length, statements: insertStatements(table, cols, rows) };
+ };
- const summary = {};
+ let summary = {};
try {
- for (const table of TABLES) {
- const cols = sqliteJson(`PRAGMA table_info(${sqlIdent(table)})`).map((r) => r.name);
- if (!cols.length) {
- summary[table] = 'absent (skipped)';
- continue;
+ if (emit) {
+ // --emit keeps ONE file per table: those are applied by hand, and numbered fragments would only add
+ // ordering rope to a manual run. Nothing is written to a DB, so there is nothing to pace or verify —
+ // the header on 0_wipe.sql puts the target check on whoever applies them.
+ writeFileSync(resolve(emit, '0_wipe.sql'), EMIT_WIPE_HEADER + wipeSql());
+ for (const table of TABLES) {
+ const read = readTable(table);
+ if (!read) {
+ summary[table] = 'absent (skipped)';
+ continue;
+ }
+ summary[table] = read.rowCount;
+ writeFileSync(resolve(emit, `${table}.sql`), read.statements.join(''));
}
- const rows = sqliteJson(`SELECT * FROM ${sqlIdent(table)}`);
- const inserts = insertStatements(table, cols, rows).join('');
- summary[table] = rows.length;
- if (emit) writeFileSync(resolve(emit, `${table}.sql`), inserts);
- else if (inserts) applyFile(table, inserts); // wipe already cleared it; skip an empty INSERT batch
+ } else {
+ summary = runShip({
+ tables: TABLES,
+ readTable,
+ wipeSql: wipeSql(),
+ apply: applyFile,
+ sleep: sleepSync,
+ readCounts: (expected) => readShippedCounts(d1Name, remote, expected),
+ maxStatements,
+ paceMs,
+ });
}
} finally {
if (tmp) rmSync(tmp, { recursive: true, force: true });
}
+
console.log(
JSON.stringify(
{ workDb, target: emit ? `emit:${emit}` : remote ? 'D1:remote' : 'D1:local', rows: summary },
diff --git a/scripts/ship-related-persons.test.mjs b/scripts/ship-related-persons.test.mjs
index b15612d1..39e99d17 100644
--- a/scripts/ship-related-persons.test.mjs
+++ b/scripts/ship-related-persons.test.mjs
@@ -7,9 +7,13 @@ import {
parseMinLinks,
resolveD1Name,
insertStatements,
+ chunkStatements,
+ runShip,
+ assertShippedCounts,
sqlLiteral,
sqlIdent,
TABLES,
+ WIPE_ORDER,
} from './ship-related-persons.mjs';
test('sqlLiteral escapes quotes, strips NUL, and NULLs non-finite/absent', () => {
@@ -70,11 +74,31 @@ test('TABLES ships parents before children and covers the served related-persons
'declared_interests',
'interest_links',
'interest_link_authorities',
+ 'interest_link_evidence',
]) {
assert.ok(TABLES.includes(t), `missing ${t}`);
}
});
+test('the evidence seal ships AFTER the links it references, and is wiped BEFORE them', () => {
+ // D1 enforces foreign keys, so ordering is not cosmetic: inserting a seal before its link fails,
+ // and deleting a link while a seal still references it fails the re-seed at interest_links.
+ assert.ok(
+ TABLES.indexOf('interest_link_evidence') > TABLES.indexOf('interest_links'),
+ 'a seal inserted before its link violates the FK',
+ );
+ assert.ok(
+ WIPE_ORDER.indexOf('interest_link_evidence') < WIPE_ORDER.indexOf('interest_links'),
+ 'a link deleted while its seal survives violates the FK',
+ );
+});
+
+test('every shipped table is wiped, and every wiped table is real', () => {
+ // A table added to TABLES but forgotten in WIPE_ORDER accumulates stale rows on every re-ship —
+ // the surface would then carry evidence for links that no longer exist.
+ for (const t of TABLES) assert.ok(WIPE_ORDER.includes(t), `${t} ships but is never wiped`);
+});
+
test('assertD1TargetAuthorized: declared env + allowlisted name + (name↔id) before a remote wipe (T48)', () => {
const ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const ok = {
@@ -181,3 +205,126 @@ test('related_persons_internal (relative-name PII) is NOT shipped to the served
// build/work DB only. If a real read path is ever added, ship it deliberately and revisit anonymization.
assert.ok(!TABLES.includes('related_persons_internal'));
});
+
+// The first full-corpus ship put 516k rows through in one hour, p90 batch 18.2s, and the database then
+// returned „internal error" for hours. Chunking bounds what a single request carries; these lock the
+// boundaries so a refactor cannot quietly restore the one-request-per-table shape.
+test('chunkStatements splits into request-sized groups and preserves order', () => {
+ const stmts = Array.from({ length: 7 }, (_, i) => `S${i};`);
+ assert.deepEqual(chunkStatements(stmts, 3), [
+ ['S0;', 'S1;', 'S2;'],
+ ['S3;', 'S4;', 'S5;'],
+ ['S6;'],
+ ]);
+ assert.deepEqual(chunkStatements(stmts, 100), [stmts], 'fits in one request');
+ assert.deepEqual(chunkStatements([], 3), [], 'nothing to ship, nothing to send');
+});
+
+test('chunkStatements refuses a non-positive size rather than looping forever', () => {
+ assert.throws(() => chunkStatements(['a'], 0), /positive integer/);
+ assert.throws(() => chunkStatements(['a'], -1), /positive integer/);
+ assert.throws(() => chunkStatements(['a'], 1.5), /positive integer/);
+});
+
+// The ship wipes, then writes over SEVERAL requests with no cross-request transaction. Before this, a
+// failure part-way exited 0 and the surface silently served a partial corpus — while the READ path
+// (EOP hydrate) already refused on a row-count mismatch. Same standard on the destructive side.
+test('assertShippedCounts passes when the target holds exactly what was shipped', () => {
+ assert.doesNotThrow(() =>
+ assertShippedCounts({ persons: 3, interest_links: 2 }, { persons: 3, interest_links: 2 }),
+ );
+});
+
+test('assertShippedCounts fails on a short table and names the numbers', () => {
+ assert.throws(
+ () => assertShippedCounts({ persons: 3, interest_links: 2 }, { persons: 3, interest_links: 1 }),
+ (e) =>
+ /interest_links: shipped 2, target has 1/.test(e.message) &&
+ /verification FAILED/.test(e.message),
+ );
+});
+
+test('assertShippedCounts fails closed when the read-back returned nothing', () => {
+ assert.throws(
+ () => assertShippedCounts({ persons: 3 }, {}),
+ /persons: shipped 3, target has no answer/,
+ );
+});
+
+test('assertShippedCounts ignores tables the ship skipped as absent', () => {
+ assert.doesNotThrow(() =>
+ assertShippedCounts({ persons: 1, gone: 'absent (skipped)' }, { persons: 1 }),
+ );
+});
+
+// These drive the REAL ship path, not the helpers in isolation. That distinction is the whole point:
+// with the previous shape — helpers unit-tested, main() calling them — reverting the call site to one
+// request per table, and deleting the verification line outright, BOTH left the suite fully green.
+const shipHarness = (over = {}) => {
+ const calls = [];
+ const naps = [];
+ const source = over.source ?? {
+ persons: { rowCount: 5, statements: ['A;', 'B;', 'C;', 'D;', 'E;'] },
+ declarations: { rowCount: 1, statements: ['F;'] },
+ };
+ const opts = {
+ tables: over.tables ?? ['persons', 'declarations'],
+ readTable: (t) => source[t] ?? null,
+ wipeSql: 'DELETE FROM persons;',
+ apply: (name, sql) => calls.push([name, sql]),
+ sleep: (ms) => naps.push(ms),
+ readCounts: over.readCounts ?? ((expected) => ({ ...expected })),
+ maxStatements: over.maxStatements ?? 2,
+ paceMs: 500,
+ };
+ return { calls, naps, run: () => runShip(opts) };
+};
+
+test('runShip wipes, then ships every table in request-sized chunks, in order', () => {
+ const h = shipHarness();
+ const summary = h.run();
+
+ assert.deepEqual(
+ h.calls.map(([name]) => name),
+ ['0_wipe', 'persons.1', 'persons.2', 'persons.3', 'declarations'],
+ 'wipe first, chunks numbered so a failed request is identifiable, single-chunk table stays bare',
+ );
+ assert.deepEqual(
+ h.calls.map(([, sql]) => sql),
+ ['DELETE FROM persons;', 'A;B;', 'C;D;', 'E;', 'F;'],
+ );
+ assert.deepEqual(summary, { persons: 5, declarations: 1 });
+});
+
+// THE regression this exists to prevent: the counter used to restart per table, so every table
+// boundary — including wipe → first insert, the most destructive transition in the run — was unpaced.
+test('runShip paces every request boundary, including wipe → first insert', () => {
+ const h = shipHarness();
+ h.run();
+ assert.equal(h.calls.length, 5);
+ assert.deepEqual(
+ h.naps,
+ [500, 500, 500, 500],
+ 'one gap between each pair of requests: none before the first, none after the last, and none skipped at a table boundary',
+ );
+});
+
+test('runShip verifies what landed — a short table fails the run', () => {
+ const h = shipHarness({ readCounts: (expected) => ({ ...expected, persons: 4 }) });
+ assert.throws(() => h.run(), /ship verification FAILED[\s\S]*persons: shipped 5, target has 4/);
+});
+
+test('runShip fails the run when the read-back itself could not answer', () => {
+ const h = shipHarness({ readCounts: () => ({}) });
+ assert.throws(() => h.run(), /no answer/);
+});
+
+test('runShip skips a table absent from the work DB without shipping or verifying it', () => {
+ const h = shipHarness({ tables: ['persons', 'declarations', 'ghost'] });
+ const summary = h.run();
+ assert.equal(summary.ghost, 'absent (skipped)');
+ assert.ok(
+ !h.calls.some(([name]) => name.startsWith('ghost')),
+ 'an absent table must issue no request',
+ );
+});
diff --git a/scripts/ship-reseed.test.mjs b/scripts/ship-reseed.test.mjs
index 4864029d..44020c5c 100644
--- a/scripts/ship-reseed.test.mjs
+++ b/scripts/ship-reseed.test.mjs
@@ -15,6 +15,9 @@ import { wipeSql, TABLES } from './ship-related-persons.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const MIG = resolve(HERE, '..', 'packages/db/migrations/0003_related_persons_foundation.sql');
+// 0006 adds interest_link_evidence, which references interest_links — so it is part of the FK graph
+// this test exists to police, and the wipe must clear it before its parent (#279, ADR-0033).
+const MIG_EVIDENCE = resolve(HERE, '..', 'packages/db/migrations/0009_interest_link_evidence.sql');
// Stub only the two 0000 tables 0002's FKs reference (bidders, authorities) — a PK column is all a FK needs —
// then apply the real 0002 and seed one row down every FK chain so a parent delete has live children.
@@ -25,6 +28,7 @@ function newPopulatedDb() {
CREATE TABLE bidders(id TEXT PRIMARY KEY);
CREATE TABLE authorities(id TEXT PRIMARY KEY);
.read ${MIG}
+.read ${MIG_EVIDENCE}
INSERT INTO bidders(id) VALUES('eik:1');
INSERT INTO authorities(id) VALUES('auth:1');
INSERT INTO persons(id,name) VALUES('p1','П Тест');
@@ -33,6 +37,7 @@ INSERT INTO declared_interests(id,declaration_id,entity_raw,entity_key,kind) VAL
INSERT INTO interest_links(id,link_key,person_id,bidder_id,eik,entity_key,matcher_version,publish_tier,relation,status) VALUES('il1','p1|1','p1','eik:1','1','e','v1','B_distinctive','owns','published');
INSERT INTO interest_link_authorities(link_key,authority_id,authority_name) VALUES('p1|1','auth:1','A');
INSERT INTO related_persons_internal(id,declaration_id,related_name,related_kind) VALUES('rp1','d1','X','related_person');
+INSERT INTO interest_link_evidence(link_key,evidence_kind,matched_fact,lookup_date,rules_version,live_status) VALUES('p1|1','document','role:owner:CR_F_19_L','2026-08-05','tr-rules-1','live');
`;
execFileSync('sqlite3', ['-bail', db], { input: setup, stdio: 'pipe' });
return { dir, db };
diff --git a/scripts/tr/cache.mjs b/scripts/tr/cache.mjs
new file mode 100644
index 00000000..764c16f2
--- /dev/null
+++ b/scripts/tr/cache.mjs
@@ -0,0 +1,278 @@
+// The deed cache — resumability, and the PII rail (issue #279, ADR-0033 decision 5).
+//
+// A registry deed contains third-party personal data: the names of owners and managers who hold no
+// public office, and the company's street address. Two rails follow from that, and both live here:
+//
+// 1. The INDEX stores no name at all — ЕИК, dates, codes, verdicts, and a hash of the body. Names
+// exist only in the raw JSON under git-ignored scratch/, are read only to produce a boolean, and
+// never enter a public table, a response or a log. A hash rather than an excerpt, because an
+// excerpt of a deed IS third-party personal data.
+// 2. Nothing may carry a STANDALONE ten-digit run. That is the ЕГН shape, and the check is sound
+// precisely because an ЕИК is 9 or 13 digits — never 10 — so it cannot reject a legitimate
+// identifier. „Standalone" is load-bearing: a 13-digit ЕИК contains ten-digit substrings, so an
+// unanchored match would refuse every клон. ЕГН was absent from every payload examined; this is
+// the rail for the day one leaks.
+//
+// Resumability is the other job: the register throttles hard and a 429 ends the run (client.mjs), so a
+// crawl must be able to pick up exactly where it stopped without re-requesting what it already has.
+
+import { DatabaseSync } from 'node:sqlite';
+import fs from 'node:fs';
+import path from 'node:path';
+import { safeEik } from './paths.mjs';
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY,
+ status TEXT NOT NULL, -- fetched | outside_tr
+ http_status INTEGER,
+ fetched_at TEXT NOT NULL,
+ raw_path TEXT, -- relative to the raw dir; the ONLY place names live
+ body_sha256 TEXT, -- integrity + change detection, never an excerpt
+ legal_form_code INTEGER,
+ legal_form_verdict TEXT, -- closely_held | joint_stock | unknown (unknown WITHHOLDS)
+ seat_normalized TEXT, -- settlement only; never a street address (ADR-0010 item 3)
+ seat_entry_date TEXT,
+ latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1,
+ outside_reason TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_deeds_status ON deeds(status);
+CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+`;
+
+/** Open (creating if absent) the cache at `file`. Idempotent — never wipes an existing cache. */
+export function openCache(file) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const db = new DatabaseSync(file);
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec(SCHEMA);
+ return db;
+}
+
+// ── the ЕГН rail ──────────────────────────────────────────────────────────────
+// ANCHORED, and that is the whole correctness of the rail. An ЕИК is 9 or 13 digits — never 10 — so
+// a ten-digit run cannot be a legitimate identifier here. But that reasoning only holds when the run
+// is matched as a WHOLE: an unanchored /\d{10}/ matches INSIDE the 13-digit ЕИК of a клон, and
+// rawPath on the fetched path is `.json`, so every branch office would be refused and the crawl
+// would abort on the first one.
+const EGN_SHAPE = /(? `${r.eik}.json`),
+ );
+ for (const name of names) {
+ if (!name.endsWith('.json') || known.has(name)) continue;
+ // The same ENOENT tolerance the expired loop above has, and for a sharper reason here: this loop
+ // runs AFTER the DB DELETE has committed, so an unguarded throw half-purges — rows gone, files
+ // still on disk — and reports the whole run as failed. The listing is a snapshot, so a name can
+ // legitimately be gone by the time we reach it (a concurrent purge, an operator clearing scratch).
+ // Already gone is the goal state. Anything else still surfaces: a purge that cannot delete has
+ // left third-party names on disk, and reporting success would be the failure it exists to prevent.
+ try {
+ unlink(path.join(rawDir, name));
+ orphans++;
+ } catch (e) {
+ if (e.code !== 'ENOENT') throw e;
+ }
+ }
+ return { rows: expired.length, files, orphans };
+}
diff --git a/scripts/tr/cache.test.mjs b/scripts/tr/cache.test.mjs
new file mode 100644
index 00000000..a7357ec8
--- /dev/null
+++ b/scripts/tr/cache.test.mjs
@@ -0,0 +1,293 @@
+// node:test — the deed cache. Its job is to make the crawl resumable and to hold the PII rail.
+//
+// The rail (ADR-0033 decision 5): the INDEX stores no name at all — only ЕИК, dates, codes, verdicts
+// and a body hash. Names exist solely in the raw JSON under git-ignored scratch/, are read only to
+// produce a boolean, and never enter a public table, a response or a log. The ten-digit refusal below
+// is the ЕГН guard, and it is sound precisely because an ЕИК is 9 or 13 digits — never 10.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {
+ openCache,
+ upsertDeed,
+ markOutsideTr,
+ pendingEiks,
+ readDeed,
+ coverage,
+ purgeExpired,
+ RETENTION_DAYS,
+} from './cache.mjs';
+
+function tmpDb() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-cache-'));
+ return { dir, file: path.join(dir, 'tr-cache.sqlite') };
+}
+const withCache = (fn) => {
+ const { dir, file } = tmpDb();
+ const db = openCache(file);
+ try {
+ return fn(db);
+ } finally {
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+};
+
+const deed = (over = {}) => ({
+ eik: '115536179',
+ httpStatus: 200,
+ fetchedAt: '2026-08-05T10:00:00Z',
+ rawPath: 'deeds/115536179.json',
+ bodySha256: 'a'.repeat(64),
+ legalFormCode: 4,
+ legalFormVerdict: 'closely_held',
+ seatNormalized: 'ПЛОВДИВ',
+ seatEntryDate: '2014-01-23',
+ latestOwnEntryDate: '2013-07-16',
+ ...over,
+});
+
+test('openCache is idempotent — re-opening an existing cache preserves rows', () => {
+ const { dir, file } = tmpDb();
+ let db = openCache(file);
+ upsertDeed(db, deed());
+ db.close();
+ db = openCache(file); // must not wipe
+ assert.equal(readDeed(db, '115536179')?.eik, '115536179');
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+});
+
+test('upsertDeed replaces on re-fetch rather than duplicating', () =>
+ withCache((db) => {
+ upsertDeed(db, deed());
+ upsertDeed(db, deed({ seatNormalized: 'СОФИЯ', fetchedAt: '2026-09-01T10:00:00Z' }));
+ assert.equal(coverage(db, ['115536179']).fetched, 1);
+ assert.equal(readDeed(db, '115536179').seatNormalized, 'СОФИЯ');
+ }));
+
+test('pendingEiks returns only what is not yet cached — this is what makes a run resumable', () =>
+ withCache((db) => {
+ upsertDeed(db, deed({ eik: '115536179' }));
+ markOutsideTr(db, '204556676', 'BULSTAT association');
+ const want = ['115536179', '204556676', '201122335', '203445566'];
+ assert.deepEqual(pendingEiks(db, want).sort(), ['201122335', '203445566']);
+ }));
+
+test('a stale deed becomes pending again past the TTL, a fresh one does not', () =>
+ withCache((db) => {
+ upsertDeed(db, deed({ fetchedAt: '2026-01-01T00:00:00Z' })); // long past
+ upsertDeed(db, deed({ eik: '201122335', fetchedAt: '2026-08-05T00:00:00Z' }));
+ const now = new Date('2026-08-05T12:00:00Z');
+ assert.deepEqual(pendingEiks(db, ['115536179', '201122335'], { maxAgeDays: 35, now }), [
+ '115536179',
+ ]);
+ }));
+
+test('coverage reports the fraction cached — the input to the fail-closed load gate', () =>
+ withCache((db) => {
+ upsertDeed(db, deed({ eik: '115536179' }));
+ upsertDeed(db, deed({ eik: '201122335' }));
+ markOutsideTr(db, '204556676', 'ДЗЗД');
+ const c = coverage(db, ['115536179', '201122335', '204556676', '203445566']);
+ assert.equal(c.wanted, 4);
+ assert.equal(c.fetched, 2);
+ assert.equal(c.outsideTr, 1);
+ assert.equal(c.missing, 1);
+ // „outside ТР" is a RESOLVED outcome, not a gap: it is known and permanent, so it counts as covered.
+ assert.equal(c.covered, 3);
+ }));
+
+// ── the PII rail ──────────────────────────────────────────────────────────────
+test('the index REFUSES a value carrying a ten-digit run (the ЕГН guard)', () =>
+ withCache((db) => {
+ // Sound because an ЕИК is 9 or 13 digits, never 10 — so this can never reject a legitimate code.
+ assert.throws(
+ () => upsertDeed(db, deed({ seatNormalized: 'СОФИЯ 8001014567' })),
+ /ten-digit|ЕГН/i,
+ );
+ assert.throws(() => markOutsideTr(db, '204556676', 'подадено от 8001014567'), /ten-digit|ЕГН/i);
+ }));
+
+test('valid 9- and 13-digit codes are NOT caught by the ЕГН guard', () =>
+ withCache((db) => {
+ assert.doesNotThrow(() => upsertDeed(db, deed({ outsideReason: null })));
+ assert.doesNotThrow(() => markOutsideTr(db, '1155361790001', 'клон'));
+ }));
+
+// A 13-digit ЕИК (клон/подразделение) CONTAINS ten-digit substrings, so an unanchored /\d{10}/
+// rejects it — and rawPath on the fetched path is `.json`, derived from that very ЕИК. This is
+// the exact shape fetch-deeds.mjs writes (path.relative(rawDir, deedPath(eik))), and upsertDeed sits
+// past its JSON.parse/assertUicEcho try-catch, so a throw here aborts the whole crawl on the first
+// branch office that returns a deed. The guard's own stated soundness ("an ЕИК is 9 or 13 digits,
+// never 10") only holds if the run is matched as a WHOLE, which is why the pattern is anchored.
+test('a 13-digit ЕИК does not trip the ЕГН guard through its own derived rawPath', () =>
+ withCache((db) => {
+ assert.doesNotThrow(() =>
+ upsertDeed(db, deed({ eik: '1155361790001', rawPath: '1155361790001.json' })),
+ );
+ assert.equal(readDeed(db, '1155361790001').eik, '1155361790001');
+ }));
+
+// The rail must not be a hand-maintained allowlist of four field names: upsertDeed binds thirteen
+// values, and the next one added would bypass the check silently. Every bound value is screened.
+test('the ЕГН guard screens EVERY bound value, not a hand-picked subset', () =>
+ withCache((db) => {
+ for (const field of ['seatEntryDate', 'latestOwnEntryDate', 'fetchedAt'])
+ assert.throws(() => upsertDeed(db, deed({ [field]: '8001014567' })), /ten-digit|ЕГН/i, field);
+ }));
+
+// ...with one exemption, and it is measured rather than assumed: a sha256 hex digest is 64 chars of
+// [0-9a-f], so a standalone ten-digit run occurs in ~7% of hashes (18% unanchored). Screening it
+// would refuse roughly one deed in fourteen for no privacy gain — a hash is not an ЕГН, and it is a
+// hash precisely so that no deed content reaches the index.
+test('the body hash is exempt from the ЕГН guard — a digit run there is arithmetic, not an ЕГН', () =>
+ withCache((db) => {
+ assert.doesNotThrow(() => upsertDeed(db, deed({ bodySha256: `8001014567${'a'.repeat(54)}` })));
+ }));
+
+test('the schema exposes no column that could hold a person name', () =>
+ withCache((db) => {
+ const cols = db
+ .prepare(`SELECT name FROM pragma_table_info('deeds')`)
+ .all()
+ .map((r) => r.name);
+ for (const forbidden of ['name', 'person', 'owner', 'manager', 'holder', 'full_name'])
+ assert.ok(!cols.includes(forbidden), `deeds.${forbidden} must not exist (PII rail)`);
+ // A hash, never an excerpt — an excerpt of a deed is third-party personal data.
+ assert.ok(cols.includes('body_sha256'));
+ }));
+
+test('markOutsideTr is permanent-by-intent and records WHY', () =>
+ withCache((db) => {
+ markOutsideTr(db, '204556676', 'ДЗЗД — BULSTAT, not TR');
+ const row = readDeed(db, '204556676');
+ assert.equal(row.status, 'outside_tr');
+ assert.match(row.outsideReason, /ДЗЗД/);
+ }));
+
+// ── retention (ADR-0033 decision 5) ───────────────────────────────────────────
+// The ADR promises „a 35-day retention and a purge step in the same job". Freshness (pendingEiks'
+// maxAgeDays) only makes a row pending again — it re-REQUESTS, it never deletes. Only this purge
+// removes the third-party names in the raw deed, so these tests are the difference between a stated
+// TTL and an enforced one.
+function withRaw(fn) {
+ const { dir, file } = tmpDb();
+ const rawDir = path.join(dir, 'deeds');
+ fs.mkdirSync(rawDir, { recursive: true });
+ const db = openCache(file);
+ try {
+ return fn(db, rawDir);
+ } finally {
+ db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+}
+const writeRaw = (rawDir, eik) =>
+ fs.writeFileSync(path.join(rawDir, `${eik}.json`), '{"names":"third-party PII"}');
+
+test('purgeExpired deletes the raw deed AND its row past the retention window', () =>
+ withRaw((db, rawDir) => {
+ const now = new Date('2026-08-05T00:00:00Z');
+ upsertDeed(db, deed({ eik: '115536179', fetchedAt: '2026-05-01T00:00:00Z' })); // ~96 days old
+ upsertDeed(db, deed({ eik: '201122335', fetchedAt: '2026-08-01T00:00:00Z' })); // 4 days old
+ writeRaw(rawDir, '115536179');
+ writeRaw(rawDir, '201122335');
+
+ const res = purgeExpired(db, rawDir, { retentionDays: 35, now });
+ assert.equal(res.rows, 1);
+ assert.equal(res.files, 1);
+ assert.equal(fs.existsSync(path.join(rawDir, '115536179.json')), false, 'PII must be gone');
+ assert.equal(readDeed(db, '115536179'), null);
+ // The in-window deed is untouched — a purge that also evicted live cache would force a re-crawl,
+ // which is the one thing the pacing exists to avoid.
+ assert.equal(fs.existsSync(path.join(rawDir, '201122335.json')), true);
+ assert.ok(readDeed(db, '201122335'));
+ }));
+
+test('purgeExpired removes orphaned raw deeds — unreachable data is pure retained PII', () =>
+ withRaw((db, rawDir) => {
+ upsertDeed(db, deed({ eik: '115536179', fetchedAt: '2026-08-01T00:00:00Z' }));
+ writeRaw(rawDir, '115536179');
+ writeRaw(rawDir, '204556676'); // no index row: no read path can ever reach it
+ const res = purgeExpired(db, rawDir, { now: new Date('2026-08-05T00:00:00Z') });
+ assert.equal(res.orphans, 1);
+ assert.equal(fs.existsSync(path.join(rawDir, '204556676.json')), false);
+ assert.equal(fs.existsSync(path.join(rawDir, '115536179.json')), true);
+ }));
+
+test('purgeExpired defaults to the 35-day window and tolerates an already-missing file', () =>
+ withRaw((db, rawDir) => {
+ assert.equal(RETENTION_DAYS, 35);
+ upsertDeed(db, deed({ eik: '115536179', fetchedAt: '2026-06-25T00:00:00Z' })); // 41 days
+ upsertDeed(db, deed({ eik: '201122335', fetchedAt: '2026-07-15T00:00:00Z' })); // 21 days
+ // No raw file on disk for the expired row: already gone is the goal state, not an error.
+ const res = purgeExpired(db, rawDir, { now: new Date('2026-08-05T00:00:00Z') });
+ assert.equal(res.rows, 1);
+ assert.equal(res.files, 0);
+ assert.ok(readDeed(db, '201122335'), 'the 21-day-old deed is inside the window');
+ }));
+
+test('purgeExpired tolerates an orphan that vanished under it, and keeps sweeping', () => {
+ // The benign race: readdirSync lists a name, and it is gone by the time unlink runs (a concurrent
+ // purge, an operator clearing scratch/). The expired loop right above has always tolerated this;
+ // the orphan loop did not, and it throws AFTER the DB DELETE has committed — so the run half-purges,
+ // reports "purge failed", and leaves the operator unable to tell "already gone" from "an orphan
+ // still holding third-party names". Injected rather than staged, because the race cannot be timed
+ // from a test; the injection seam matches the one httpGet/sleep/now already use in this codebase.
+ return withRaw((db, rawDir) => {
+ upsertDeed(db, deed({ eik: '115536179', fetchedAt: '2026-08-01T00:00:00Z' }));
+ writeRaw(rawDir, '115536179');
+ writeRaw(rawDir, '204556676'); // orphan 1 — disappears under us
+ writeRaw(rawDir, '831391124'); // orphan 2 — must still be swept
+
+ const seen = [];
+ const res = purgeExpired(db, rawDir, {
+ now: new Date('2026-08-05T00:00:00Z'),
+ unlink: (p) => {
+ seen.push(path.basename(p));
+ if (p.endsWith('204556676.json')) {
+ const err = new Error('ENOENT: no such file or directory');
+ err.code = 'ENOENT';
+ throw err;
+ }
+ fs.unlinkSync(p);
+ },
+ });
+
+ assert.deepEqual(seen.sort(), ['204556676.json', '831391124.json'], 'both orphans attempted');
+ assert.equal(
+ res.orphans,
+ 1,
+ 'a file that was already gone was not deleted BY US — do not count it',
+ );
+ assert.equal(fs.existsSync(path.join(rawDir, '831391124.json')), false, 'the sweep continued');
+ assert.equal(
+ fs.existsSync(path.join(rawDir, '115536179.json')),
+ true,
+ 'the live deed is untouched',
+ );
+ });
+});
+
+test('purgeExpired still refuses loudly on an orphan it could not delete for a REAL reason', () => {
+ // The other half, and the reason the guard is ENOENT-only. A permission error means retained PII is
+ // still on disk; reporting success would be the failure mode the purge exists to prevent.
+ return withRaw((db, rawDir) => {
+ writeRaw(rawDir, '204556676');
+ assert.throws(
+ () =>
+ purgeExpired(db, rawDir, {
+ now: new Date('2026-08-05T00:00:00Z'),
+ unlink: () => {
+ const err = new Error('EACCES: permission denied');
+ err.code = 'EACCES';
+ throw err;
+ },
+ }),
+ /EACCES/,
+ );
+ });
+});
diff --git a/scripts/tr/client.mjs b/scripts/tr/client.mjs
new file mode 100644
index 00000000..473f5e30
--- /dev/null
+++ b/scripts/tr/client.mjs
@@ -0,0 +1,171 @@
+// HTTP client for the Търговски регистър public API (issue #279, ADR-0033).
+//
+// TLS: PLAIN HTTPS, verified against the system roots at Node's secure default. Do NOT reach for
+// scripts/cacbg/tls.mjs here. That module exists because register.cacbg.bg serves an incomplete chain,
+// so we pin ITS leaf key (ADR-0011) — `getPinned` hard-refuses every other host by design. This host
+// chains correctly, so ordinary verification is available and is strictly stronger than a pin we would
+// have to hand-maintain. Copying the pinning across "for consistency" would weaken this leg.
+//
+// Rate limiting: the register throttles, and when it does the block is SUSTAINED. An earlier spike saw
+// HTTP 429 at roughly 50 cumulative requests ending in a burst, and thereafter 429 to every subsequent
+// request — including simple /Deeds/{eik} calls that had worked seconds before. There is no
+// `Retry-After` and no `X-RateLimit-*` header, so a client cannot pace against a published budget; it
+// can only avoid tripping one. That makes a 429 an instruction to STOP, not a transient to retry
+// through: `politeTrGet` retries 5xx and network faults with growing backoff, and never retries a 429.
+// (#279 §3's "5 retries with growing backoff" and the decision that a 429 ends the run are consistent
+// only if the retry set excludes 429.) The limiter is the operator's only way to express a rate
+// preference, and tuning around it empirically is what spec §3.3's "NEVER bulk-scrape" forbids.
+
+import https from 'node:https';
+import { safeEik } from './paths.mjs';
+
+export const TR_HOST = 'portal.registryagency.bg';
+
+// Identify the crawler honestly. The operator's only lever on us is the rate limiter, so at minimum
+// they should be able to see who is calling and where to complain.
+export const TR_USER_AGENT =
+ 'sigma-bot/1.0 (+https://github.com/midt-bg/sigma) contact: via repo issues';
+
+/** Thrown on HTTP 429. Distinguishable so the crawler can end the run instead of marking anything. */
+export class RateLimitError extends Error {
+ constructor(url) {
+ super(`REFUSE TO CONTINUE: ${TR_HOST} returned 429 for ${url} — the block is sustained; stop`);
+ this.name = 'RateLimitError';
+ this.url = url;
+ }
+}
+
+/** Refuse any URL that is not an https request to the register. Call before touching the network. */
+export function assertTrHost(url) {
+ let u;
+ try {
+ u = new URL(String(url));
+ } catch {
+ throw new Error(`bad URL: ${url}`);
+ }
+ if (u.protocol !== 'https:' || u.hostname !== TR_HOST) {
+ throw new Error(`refusing non-${TR_HOST} host: ${url}`);
+ }
+ return url;
+}
+
+/** The public deed endpoint for one ЕИК. Public JSON, no authentication. */
+export function deedUrl(eik) {
+ return `https://${TR_HOST}/CR/api/Deeds/${safeEik(eik)}`;
+}
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Default transport: `node:https`, system roots, secure defaults.
+ *
+ * NOT `fetch`. Measured against the live endpoint on 2026-08-05, the identical request returns
+ * **HTTP 500 with an empty body via undici's fetch and HTTP 200 with the full 34,398-byte deed via
+ * node:https** — same URL, same accept header, same user-agent. The server rejects something undici
+ * adds to the wire (encoding negotiation / connection handling); it is not an auth or rate-limit
+ * problem, and retrying only multiplies the failure. `scripts/cacbg/tls.mjs` is on node:https too, so
+ * both crawl legs now share one transport primitive.
+ *
+ * `rejectUnauthorized` is left at its secure default and named here on purpose: the sibling CACBG
+ * module deliberately does NOT verify against system roots (it pins a leaf instead, ADR-0011), so a
+ * reader comparing the two needs to see that this leg takes the ordinary, stronger path.
+ */
+/**
+ * Ceiling on a single response body. A measured deed is ~34 KB, so 8 MB is ~240× the real thing —
+ * this bounds abuse, not the register, and can never refuse a large-but-legitimate company.
+ */
+export const MAX_BODY_BYTES = 8 * 1024 * 1024;
+
+/**
+ * Read one response into a Buffer, refusing past `maxBytes`.
+ *
+ * The timeout on the request bounds how long a response may STALL; nothing bounded how much it may
+ * SEND. Without a counter the crawler buffers whatever arrives, so a wedged or hostile endpoint decides
+ * how much memory this process holds. It also composes badly with the parser: the erasure regex in
+ * deed.mjs backtracks quadratically on unclosed markup (measured 34K→3.7ms, 68K→13.5ms, 136K→53.2ms,
+ * 272K→215.4ms — ×4 per doubling), and that path is reachable only through a body large enough to make
+ * it matter. One byte counter bounds both, and destroying the request is the load-bearing half: merely
+ * rejecting the promise would leave the socket draining the rest of the response.
+ *
+ * Separated from `httpsGet` so it is testable without TLS or a live socket — `res` needs only to be an
+ * emitter of 'data'/'end'/'error', which is the same injection posture as politeTrGet's `httpGet`.
+ */
+export function collectBody(res, { req, maxBytes = MAX_BODY_BYTES } = {}) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ let done = false;
+ const fail = (err) => {
+ if (done) return;
+ done = true;
+ req?.destroy(err); // tear the socket down; do not sit and drain what we already refused
+ reject(err);
+ };
+ res.on('data', (c) => {
+ if (done) return;
+ size += c.length;
+ if (size > maxBytes) {
+ fail(new Error(`response too large: over ${maxBytes} bytes — refusing to buffer it`));
+ return;
+ }
+ chunks.push(c);
+ });
+ res.on('end', () => {
+ if (done) return;
+ done = true;
+ resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) });
+ });
+ res.on('error', fail);
+ });
+}
+
+export function httpsGet(url, { timeoutMs = 20_000, maxBytes = MAX_BODY_BYTES } = {}) {
+ return new Promise((resolve, reject) => {
+ const req = https.get(
+ url,
+ { headers: { accept: 'application/json', 'user-agent': TR_USER_AGENT } },
+ (res) => collectBody(res, { req, maxBytes }).then(resolve, reject),
+ );
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout after ${timeoutMs}ms: ${url}`)));
+ req.on('error', reject);
+ });
+}
+
+/**
+ * GET with polite retries. 5xx and network faults retry with growing backoff; 2xx/4xx return as-is;
+ * **429 throws `RateLimitError` immediately and is never retried**.
+ * @param {string} url
+ * @param {{httpGet?:Function, sleep?:Function, tries?:number, backoffMs?:number}} [opts]
+ * `httpGet` and `sleep` are the injection seam — the whole retry policy is testable offline.
+ */
+export async function politeTrGet(url, opts = {}) {
+ const { httpGet = httpsGet, sleep = wait, tries = 5, backoffMs = 1000 } = opts;
+ assertTrHost(url); // before any request — a foreign host must cost zero packets
+ let backoff = backoffMs;
+ for (let attempt = 1; ; attempt++) {
+ let res;
+ try {
+ res = await httpGet(url);
+ } catch (err) {
+ if (attempt >= tries) throw err;
+ await sleep(backoff);
+ backoff *= 2;
+ continue;
+ }
+ // Checked before the retry branch: a 429 arriving mid-retry must abort the whole call, not be
+ // folded into the 5xx budget and not let a later 200 mask the block.
+ if (res.status === 429) throw new RateLimitError(url);
+ if (res.status >= 500) {
+ if (attempt >= tries) return res; // give the caller the last response to record
+ await sleep(backoff);
+ backoff *= 2;
+ continue;
+ }
+ return res;
+ }
+}
+
+/** One deed by ЕИК. Thin wrapper so callers never build the URL themselves. */
+export function trGet(eik, opts) {
+ return politeTrGet(deedUrl(eik), opts);
+}
diff --git a/scripts/tr/client.test.mjs b/scripts/tr/client.test.mjs
new file mode 100644
index 00000000..20142602
--- /dev/null
+++ b/scripts/tr/client.test.mjs
@@ -0,0 +1,197 @@
+// node:test — the Trade Register HTTP client. Offline: every test drives an injected getter.
+//
+// The load-bearing behaviour here is what the client REFUSES to do. The register rate-limits, and
+// when it does the block is sustained (an earlier spike saw HTTP 429 at ~50 cumulative requests, then
+// 429 to every subsequent call including ones that had just worked — no Retry-After, no quota header).
+// So a 429 is an instruction to stop, not a transient to retry through. ADR-0033 decision 7.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import {
+ TR_HOST,
+ TR_USER_AGENT,
+ deedUrl,
+ RateLimitError,
+ politeTrGet,
+ assertTrHost,
+ httpsGet,
+ collectBody,
+ MAX_BODY_BYTES,
+} from './client.mjs';
+
+const okRes = (body = '{}') => ({ status: 200, headers: {}, body: Buffer.from(body) });
+
+test('deedUrl builds the documented endpoint and refuses a non-ЕИК', () => {
+ assert.equal(deedUrl('115536179'), `https://${TR_HOST}/CR/api/Deeds/115536179`);
+ assert.equal(deedUrl('000696327'), `https://${TR_HOST}/CR/api/Deeds/000696327`);
+ for (const bad of ['', null, '../../etc', '115536179?x=1', '11553617x', 'ЕИК 115536179'])
+ assert.throws(() => deedUrl(bad), /unsafe|ЕИК/i, String(bad));
+});
+
+test('assertTrHost refuses every host but the register', () => {
+ assert.doesNotThrow(() => assertTrHost(`https://${TR_HOST}/CR/api/Deeds/115536179`));
+ for (const bad of [
+ 'https://evil.example/CR/api/Deeds/115536179',
+ `http://${TR_HOST}/CR/api/Deeds/115536179`, // plaintext — never
+ `https://${TR_HOST}.evil.example/x`, // suffix trick
+ `https://register.cacbg.bg/x`, // the OTHER register: different host, different TLS posture
+ ]) {
+ assert.throws(() => assertTrHost(bad), /host/i, bad);
+ }
+});
+
+test('a 429 throws RateLimitError and is NEVER retried', async () => {
+ let calls = 0;
+ const httpGet = async () => {
+ calls++;
+ return { status: 429, headers: {}, body: Buffer.from('') };
+ };
+ await assert.rejects(
+ () => politeTrGet(deedUrl('115536179'), { httpGet, sleep: async () => {}, tries: 5 }),
+ RateLimitError,
+ );
+ // The whole point: „5 retries with growing backoff" and „429 stops the run" are only consistent
+ // if the retry set EXCLUDES 429. One call, not five.
+ assert.equal(calls, 1, 'a 429 must not be retried');
+});
+
+test('5xx IS retried with growing backoff, up to the try budget', async () => {
+ let calls = 0;
+ const waits = [];
+ const httpGet = async () => {
+ calls++;
+ return { status: 503, headers: {}, body: Buffer.from('') };
+ };
+ const res = await politeTrGet(deedUrl('115536179'), {
+ httpGet,
+ sleep: async (ms) => void waits.push(ms),
+ tries: 4,
+ });
+ assert.equal(calls, 4);
+ assert.equal(res.status, 503, 'the last response is returned, not thrown');
+ assert.deepEqual(
+ waits.map((w, i) => (i === 0 ? true : w > waits[i - 1])),
+ [true, true, true],
+ `backoff must grow: ${waits.join(',')}`,
+ );
+});
+
+test('a network throw is retried, then rethrown when the budget is spent', async () => {
+ let calls = 0;
+ const httpGet = async () => {
+ calls++;
+ throw new Error('ECONNRESET');
+ };
+ await assert.rejects(
+ () => politeTrGet(deedUrl('115536179'), { httpGet, sleep: async () => {}, tries: 3 }),
+ /ECONNRESET/,
+ );
+ assert.equal(calls, 3);
+});
+
+test('a 429 arriving mid-retry aborts immediately instead of finishing the budget', async () => {
+ const seq = [503, 503, 429, 200];
+ let calls = 0;
+ const httpGet = async () => {
+ const status = seq[calls++];
+ return { status, headers: {}, body: Buffer.from('') };
+ };
+ await assert.rejects(
+ () => politeTrGet(deedUrl('115536179'), { httpGet, sleep: async () => {}, tries: 5 }),
+ RateLimitError,
+ );
+ assert.equal(calls, 3, 'must stop AT the 429, not carry on to the 200 behind it');
+});
+
+test('200 and 404 return without any retry', async () => {
+ for (const status of [200, 404]) {
+ let calls = 0;
+ const httpGet = async () => {
+ calls++;
+ return status === 200 ? okRes() : { status, headers: {}, body: Buffer.from('') };
+ };
+ const res = await politeTrGet(deedUrl('115536179'), { httpGet, sleep: async () => {} });
+ assert.equal(res.status, status);
+ assert.equal(calls, 1);
+ }
+});
+
+test('politeTrGet refuses a foreign host before making any request', async () => {
+ let calls = 0;
+ const httpGet = async () => {
+ calls++;
+ return okRes();
+ };
+ await assert.rejects(
+ () => politeTrGet('https://evil.example/CR/api/Deeds/1', { httpGet }),
+ /host/i,
+ );
+ assert.equal(calls, 0, 'the refusal must happen before the network call');
+});
+
+test('the crawler identifies itself and points at somewhere to complain', () => {
+ // The rate limiter is the operator's only lever on us. Crawling anonymously takes away their
+ // ability to distinguish us from an abusive client, or to ask us to stop.
+ assert.match(TR_USER_AGENT, /sigma/i);
+ assert.match(TR_USER_AGENT, /https:\/\/github\.com\//);
+});
+
+test('the default transport is node:https, not fetch', () => {
+ // Measured 2026-08-05: the identical live request returns 500/empty via undici fetch and 200 with
+ // the full deed via node:https. Pinning the choice so a future "modernise to fetch" tidy-up has to
+ // confront it rather than silently break every lookup.
+ assert.equal(typeof httpsGet, 'function');
+ assert.match(httpsGet.toString(), /https\.get/);
+});
+
+test('RateLimitError carries the url and is distinguishable from a generic failure', async () => {
+ const httpGet = async () => ({ status: 429, headers: {}, body: Buffer.from('') });
+ const err = await politeTrGet(deedUrl('115536179'), { httpGet, sleep: async () => {} }).catch(
+ (e) => e,
+ );
+ assert.ok(err instanceof RateLimitError);
+ assert.ok(err instanceof Error);
+ assert.match(err.message, /115536179/);
+});
+
+// ── response size ─────────────────────────────────────────────────────────────
+// The timeout bounds how long a response may STALL; nothing bounded how large it may GROW. A deed is
+// ~34 KB measured; an unbounded reader buffers whatever arrives, so a wedged or hostile endpoint could
+// have the crawler hold an arbitrary amount of memory. It also composes badly with the parser: the
+// erasure regex in deed.mjs backtracks quadratically on unclosed markup (measured 34K→3.7ms,
+// 68K→13.5ms, 136K→53.2ms, 272K→215.4ms — ×4 per doubling), and that path is only reachable through a
+// body large enough to make it matter. One byte counter bounds both.
+test('a response past the cap is refused and the request destroyed, not buffered', async () => {
+ const res = fakeRes(200);
+ const destroyed = [];
+ const p = collectBody(res, { req: { destroy: (e) => destroyed.push(e) }, maxBytes: 1024 });
+ res.emit('data', Buffer.alloc(700));
+ res.emit('data', Buffer.alloc(700)); // 1400 > 1024
+ await assert.rejects(p, /too large|1024/i);
+ assert.equal(destroyed.length, 1, 'the socket must be torn down, not left draining');
+});
+
+test('a response under the cap still resolves with the WHOLE body', async () => {
+ const res = fakeRes(200);
+ const p = collectBody(res, { req: { destroy: () => {} }, maxBytes: 1024 });
+ res.emit('data', Buffer.from('{"uic":'));
+ res.emit('data', Buffer.from('"115536179"}'));
+ res.emit('end');
+ const out = await p;
+ assert.equal(out.status, 200);
+ assert.equal(out.body.toString(), '{"uic":"115536179"}');
+});
+
+test('the cap leaves real deeds far under it — it bounds abuse, not the register', () => {
+ // A measured deed is ~34 KB. The cap must sit well above that or it becomes a correctness bug that
+ // silently refuses large-but-legitimate companies.
+ assert.ok(MAX_BODY_BYTES >= 1_000_000, `cap ${MAX_BODY_BYTES} is too tight for a real deed`);
+});
+
+// A stand-in for an http.IncomingMessage: an emitter carrying a status code.
+function fakeRes(status) {
+ const e = new EventEmitter();
+ e.statusCode = status;
+ e.headers = {};
+ return e;
+}
diff --git a/scripts/tr/deed.mjs b/scripts/tr/deed.mjs
new file mode 100644
index 00000000..13837d7a
--- /dev/null
+++ b/scripts/tr/deed.mjs
@@ -0,0 +1,314 @@
+// Pure parser for a Търговски регистър deed (issue #279, ADR-0033). No I/O, no network, no state.
+//
+// The envelope is clean JSON, but every field's VALUE is an HTML fragment with semi-structured
+// Bulgarian inside. That inner parser is where the libel risk lives, so the order of operations below
+// is an invariant with tests on it, not a suggestion:
+//
+// decode HTML entities
+// → split into entities on record-container / hr--report
+// → drop entities marked erased
+// → strip tags WITHIN one entity
+// → separate the name from the address/stake WITHIN that entity
+// → match tokens ONLY within a single entity
+//
+// Any other order silently merges blocks. Field CR_F_19_L routinely holds several съдружници in one
+// string; matching a declarant 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.
+
+const OWNERSHIP_FIELDS = ['CR_F_18_L', 'CR_F_19_L', 'CR_F_23_L'];
+const MANAGER_FIELD = 'CR_F_7_L';
+export const ROLE_FIELDS = [MANAGER_FIELD, ...OWNERSHIP_FIELDS];
+export { OWNERSHIP_FIELDS, MANAGER_FIELD };
+
+// ── html ──────────────────────────────────────────────────────────────────────
+const ENTITIES = {
+ '"': '"',
+ ''': "'",
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ ' ': ' ',
+};
+// `String.fromCodePoint` THROWS RangeError above U+10FFFF (and on a surrogate half), and the input is
+// whatever the register put on the wire. An unguarded throw here does not stay local: it escapes
+// entityBlocks and registrySeat, past the crawl loop's refuse-and-continue block (which covers only
+// JSON.parse + assertUicEcho) and out of run() — one malformed escape in one deed ends a paced crawl
+// that has already spent its request budget, and does the same to load.mjs at decision time.
+// Out of range is not a character and cannot be part of a name, so it decodes to nothing: the rest of
+// the entity still parses, which is the difference between losing a glyph and losing the run.
+const MAX_CODE_POINT = 0x10ffff;
+const codePoint = (n) =>
+ Number.isInteger(n) && n >= 0 && n <= MAX_CODE_POINT ? String.fromCodePoint(n) : '';
+
+/** Decode the entity set the register actually emits, plus numeric escapes. FIRST step, always. */
+function decodeEntities(s) {
+ return String(s)
+ .replace(/&(?:quot|apos|amp|lt|gt|nbsp);/g, (m) => ENTITIES[m])
+ .replace(/(\d+);/g, (_, n) => codePoint(Number(n)))
+ .replace(/([0-9a-f]+);/gi, (_, n) => codePoint(parseInt(n, 16)));
+}
+
+/** Strip tags and collapse whitespace, INSIDE one already-isolated entity. */
+function stripTags(html) {
+ return String(html)
+ .replace(/ /gi, ' ')
+ .replace(/<[^>]*>/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+// An entity is erased when the register says so. MEASURED on a live deed: the marker is
+// `erasure-text-inline` (with `ui-icon-erased`) and the container carries no `field-text` paragraph at
+// all. `field-text--erased` is accepted as well — it is the other spelling reported for this register,
+// and honouring both costs nothing while assuming one costs a wrong publish.
+const ERASED_MARKER = /erasure-text-inline|ui-icon-erased|field-text--erased/;
+
+// The erasure notice, stripped so „Заличено обстоятелство." never reads as content.
+//
+// The `{0,2000}` bound is not cosmetic. With a plain `.*?` this backtracks QUADRATICALLY on markup where
+// the opening div is never closed: every opening restarts a scan to end-of-input, measured at 34K→3.3ms,
+// 68K→13.6ms, 136K→53.8ms, 272K→240ms, 1M→4.0s — ×4 per doubling. Bounding the lazy run makes each start
+// position scan a fixed window instead, which is linear: the same inputs measure 5.3 / 10.3 / 24.8 / 41.6
+// / 187ms. A real notice is one short sentence, so 2000 characters is ~80× the live shape.
+//
+// Failing to match is safe by construction: `erased` is decided independently by ERASED_MARKER above, so
+// an over-long notice still marks the block erased and liveFields still drops it. The only visible effect
+// is that its text survives into the block — which in strict mode raises the drift alarm, loudly, rather
+// than passing anything through silently.
+const ERASURE_NOTICE =
+ /
]*class=['"][^'"]*erasure-text-inline[^'"]*['"][^>]*>.{0,2000}?<\/div>/gis;
+
+/**
+ * Split one field's htmlData into the separate registered entities it holds.
+ *
+ * @param {string} html
+ * @param {{strict?:boolean}} [opts] `strict` turns the erased-with-content contradiction into a throw.
+ * @returns {{text:string, erased:boolean}[]}
+ */
+export function entityBlocks(html, { strict = false } = {}) {
+ if (html == null || String(html).trim() === '') return [];
+ const decoded = decodeEntities(html); // decode BEFORE splitting — see the order above
+ const chunks = decoded
+ .split(/]*>/i)
+ .flatMap((part) => part.split(/(?=
]*class=['"][^'"]*record-container)/i))
+ .map((c) => c.trim())
+ .filter((c) => c !== '');
+
+ const out = [];
+ for (const chunk of chunks) {
+ const erased = ERASED_MARKER.test(chunk);
+ // Read the visible text WITHOUT the erasure notice, so „Заличено обстоятелство." never counts as
+ // content and an erased block reads as empty.
+ const withoutNotice = chunk.replace(ERASURE_NOTICE, ' ');
+ const text = stripTags(withoutNotice);
+ if (erased && text !== '' && strict) {
+ throw new Error(
+ `REFUSE: an erased entity carries content (${JSON.stringify(text.slice(0, 60))}) — the ` +
+ `"erased ⇒ empty" assumption has drifted; stop rather than guess which state is in force`,
+ );
+ }
+ if (text === '' && !erased) continue; // structural noise, not an entity
+ out.push({ text, erased });
+ }
+ return out;
+}
+
+/** Every field in the deed, flattened out of sections → subDeeds → groups. */
+function allFields(deed) {
+ const out = [];
+ for (const s of deed?.sections ?? [])
+ for (const sd of s?.subDeeds ?? [])
+ for (const g of sd?.groups ?? []) for (const f of g?.fields ?? []) out.push(f);
+ return out;
+}
+
+const isoDay = (v) => (v ? String(v).slice(0, 10) : null);
+
+/**
+ * The LIVE entities of the requested field codes — the single entry point to live state.
+ * Erased entities are dropped here and nowhere else, so the rule is auditable in one place.
+ * @returns {{nameCode:string, entryDate:string|null, entryNumber:string|null, entities:string[]}[]}
+ */
+export function liveFields(deed, nameCodes, opts = {}) {
+ const want = new Set(nameCodes);
+ const out = [];
+ for (const f of allFields(deed)) {
+ if (!want.has(f.nameCode)) continue;
+ const entities = entityBlocks(f.htmlData, opts)
+ .filter((b) => !b.erased)
+ .map((b) => b.text);
+ if (entities.length === 0) continue;
+ out.push({
+ nameCode: f.nameCode,
+ // TEXT, never a number: a fieldEntryNumber like 20130716101007 exceeds 2^53 once combined.
+ entryNumber: f.fieldEntryNumber == null ? null : String(f.fieldEntryNumber),
+ entryDate: isoDay(f.fieldEntryDate),
+ entities,
+ });
+ }
+ return out;
+}
+
+// ── names ─────────────────────────────────────────────────────────────────────
+/**
+ * Name tokens: NFC, upper case, split on non-letters, keep tokens of length ≥2.
+ *
+ * Dropping 1-character tokens is what makes „Г. И. Петров" a ONE-token name rather than a three-token
+ * one — an abbreviated name can then never reach the ≥3 tokens rung 2 requires, instead of passing on
+ * initials that match half the register. Latin letters are kept (never folded onto Cyrillic
+ * look-alikes) so a homoglyph is a non-match rather than a false match, matching companyNameKey's
+ * posture in packages/shared/src/company-name-key.ts.
+ *
+ * A near-twin of the module-private `holderTokens` in scripts/cacbg/parse.mjs — deliberately
+ * re-implemented rather than imported, because that module pulls in fast-xml-parser and would tie
+ * these pure tests to a workspace install. Keep the two in step.
+ */
+export function personTokens(name) {
+ return String(name ?? '')
+ .normalize('NFC')
+ .toUpperCase()
+ .split(/[^\p{L}]+/u)
+ .filter((t) => [...t].length >= 2);
+}
+
+/**
+ * Does EVERY token of the declarant's name appear as a whole token of this ONE entity?
+ *
+ * Full subset, not a majority: of 301 measured matches, 46 were two-token only, which is precisely
+ * the homonym risk. Whole-token, not substring: „ПЕТРОВ" inside „ПЕТРОВА" is a different person.
+ *
+ * Callers MUST pass a single entity's text (see entityBlocks). Passing a whole field is the
+ * cross-entity bug, and no signature can prevent it — the test does.
+ */
+export function fullSubsetMatch(declarantName, entityText) {
+ const want = personTokens(declarantName);
+ if (want.length === 0) return false;
+ const have = new Set(personTokens(entityText));
+ return want.every((t) => have.has(t));
+}
+
+// ── seat ──────────────────────────────────────────────────────────────────────
+// Strip a settlement-type prefix only as a WHOLE token: „гр."/„с."/„общ."/„обл."/„ж.к." followed by a
+// dot and optional space. R9 — a loose prefix strip turns СОФИЯ into ОФИЯ and ГРАДЕЦ into АДЕЦ.
+const SETTLEMENT_PREFIX = /^(?:ГР|С|ОБЩ|ОБЛ|Ж\.К)\.\s*/u;
+
+/** Normalise a settlement name for comparison. Empty in ⇒ empty out, and empty NEVER confirms. */
+export function normalizeSettlement(raw) {
+ let s = String(raw ?? '')
+ .normalize('NFC')
+ .toUpperCase()
+ .replace(/\([^)]*\)/g, ' ') // „(столица)"
+ .trim();
+ s = s.split(/[,/]/)[0].trim(); // cut at the first comma or slash — „с. Марково, п.к. 4108"
+ s = s.replace(SETTLEMENT_PREFIX, '');
+ return s
+ .replace(/[^\p{L}\s-]/gu, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/**
+ * The company's registered settlement, from CR_F_5_L's „Населено място:" segment ONLY.
+ *
+ * ADR-0010 item 3 (addresses are never extracted) is honoured here, and the field makes that a live
+ * concern rather than a formality: CR_F_5_L also carries street, postcode, phone, fax, e-mail and
+ * website. Nothing but the settlement and the entry date leaves this function.
+ * @returns {{settlement:string, entryDate:string|null}}
+ */
+export function registrySeat(deed) {
+ for (const f of liveFields(deed, ['CR_F_5_L'])) {
+ for (const text of f.entities) {
+ const m = text.match(/Населено място:\s*([^,]+)/u);
+ if (m) return { settlement: normalizeSettlement(m[1]), entryDate: f.entryDate };
+ }
+ }
+ return { settlement: '', entryDate: null };
+}
+
+// ── legal form ────────────────────────────────────────────────────────────────
+// Codes observed empirically (#279 §3 + the spike's catalogue read). DELIBERATELY incomplete: the
+// nomenclature endpoint does not settle the enum (legalForm=4 and =10 return a byte-identical
+// catalogue), so anything absent here is `unknown` and WITHHOLDS. In particular whether ЕАД carries
+// its own code is unresolved — ООД and ЕООД turned out NOT to share one (4 vs 10), so assuming ЕАД
+// shares 5 with АД would be exactly the fail-open this bar exists to prevent.
+const FORM_CODES = new Map([
+ [1, 'closely_held'], // ЕТ
+ [4, 'closely_held'], // ООД
+ [5, 'joint_stock'], // АД
+ [6, 'joint_stock'], // КДА
+ [10, 'closely_held'], // ЕООД
+]);
+
+// The фирма's legal form is its SUFFIX under ЗТРРЮЛНЦ, and the deed envelope's `fullName` carries it
+// („ПИМК" ООД) while CR_F_2_L is bare („ПИМК"). So the bar has a second, independent signal at zero
+// extra cost.
+//
+// DELIBERATELY a twin of classify.mjs's JOINT_STOCK rather than an import, for the same reason
+// personTokens twins parse.mjs's holderTokens: the dependency direction here is cacbg → tr (load.mjs
+// imports this module), so importing back out of scripts/cacbg/ would close a cycle across the two
+// directories. The two are pinned byte-identical by a test in deed.test.mjs — this comment used to
+// claim КДА was missing from classify.mjs, which stopped being true in 5f64f5c, and an unenforced
+// „keep these in step" note is exactly how that happens.
+export const JOINT_SUFFIX = /(?:^|[\s"„“”«»])(АД|ЕАД|АДСИЦ|КДА)[\s"„“”«»]*$/u;
+const CLOSELY_SUFFIX = /(?:^|[\s"„“”«»])(ООД|ЕООД|ЕТ|ДЗЗД|КД|СД|КООПЕРАЦИЯ)[\s"„“”«»]*$/u;
+
+/**
+ * Legal-form verdict — a union of the numeric code and the mandated фирма suffix.
+ * Either signal saying joint-stock bars the link. Neither able to say ⇒ `unknown`, which withholds.
+ * @returns {{code:number|null, codeVerdict:string, suffixVerdict:string, verdict:string}}
+ */
+export function registryLegalForm(deed) {
+ const code = typeof deed?.legalForm === 'number' ? deed.legalForm : null;
+ const codeVerdict = (code != null && FORM_CODES.get(code)) || 'unknown';
+
+ const name = String(deed?.fullName ?? '')
+ .normalize('NFC')
+ .toUpperCase()
+ .trim();
+ const suffixVerdict = JOINT_SUFFIX.test(name)
+ ? 'joint_stock'
+ : CLOSELY_SUFFIX.test(name)
+ ? 'closely_held'
+ : 'unknown';
+
+ const verdict =
+ codeVerdict === 'joint_stock' || suffixVerdict === 'joint_stock'
+ ? 'joint_stock'
+ : codeVerdict === 'closely_held' || suffixVerdict === 'closely_held'
+ ? 'closely_held'
+ : 'unknown';
+ return { code, codeVerdict, suffixVerdict, verdict };
+}
+
+// ── refutation input ──────────────────────────────────────────────────────────
+/**
+ * Latest entry date across the LIVE ownership fields, or null when none survives.
+ *
+ * The trap this avoids was present in the first company sampled: CR_F_23_L sits in the CURRENT deed
+ * dated 2013-07-16 carrying only „Заличено обстоятелство.". Counted naively it becomes the latest
+ * ownership entry and can refute a link it says nothing about. liveFields drops it.
+ */
+export function latestOwnershipEntryDate(deed) {
+ const dates = liveFields(deed, OWNERSHIP_FIELDS)
+ .map((f) => f.entryDate)
+ .filter(Boolean);
+ return dates.length ? dates.sort().at(-1) : null;
+}
+
+/**
+ * The deed we got back must be the deed we asked for.
+ *
+ * R8: Bulgarian public bodies carry ЕИК of exactly the `000…` shape, so any numeric round-trip on the
+ * path silently rewrites the identifier. Without this rail, that failure publishes a claim about a
+ * different company under the right official's name.
+ */
+export function assertUicEcho(deed, requestedEik) {
+ const got = deed?.uic == null ? null : String(deed.uic);
+ if (got !== String(requestedEik)) {
+ throw new Error(
+ `REFUSE: deed uic echo mismatch — requested ${JSON.stringify(String(requestedEik))}, ` +
+ `deed reports ${JSON.stringify(got)}`,
+ );
+ }
+ return deed;
+}
diff --git a/scripts/tr/deed.test.mjs b/scripts/tr/deed.test.mjs
new file mode 100644
index 00000000..9c539c87
--- /dev/null
+++ b/scripts/tr/deed.test.mjs
@@ -0,0 +1,391 @@
+// node:test — the deed parser. Pure, offline, and the most dangerous code in this change.
+//
+// Every fixture below is REAL markup, copied verbatim from a live deed (ЕИК 115536179, fetched
+// 2026-08-05) with person names replaced only where a test needs a specific shape. Writing this
+// parser against imagined markup is how the entity-boundary bug ships.
+//
+// The failure this file exists to prevent: field CR_F_19_L holds THREE separate people in one string,
+// separated by . Matching a declarant's tokens against the whole field lets
+// the given name of one person combine with the surname of another, and the result is a named public
+// claim that a specific official owns a specific company — about the wrong person. ADR-0033 decision 2.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ entityBlocks,
+ liveFields,
+ personTokens,
+ fullSubsetMatch,
+ normalizeSettlement,
+ registrySeat,
+ registryLegalForm,
+ latestOwnershipEntryDate,
+ assertUicEcho,
+ JOINT_SUFFIX,
+} from './deed.mjs';
+
+// ── real markup ───────────────────────────────────────────────────────────────
+const F19_THREE = `
ПИМК ХОЛДИНГ ГРУП АД, ЕИК/ПИК 202294392, Държава: БЪЛГАРИЯ, Размер на дяловото участие: 59980.00 лв.
ПЕНКО НЕСТОРОВ НЕСТОРОВ, Държава: БЪЛГАРИЯ, Размер на дяловото участие: 10.00 лв.
ИЛИЯН КОСТАДИНОВ ФИЛИПОВ, Държава: БЪЛГАРИЯ, Размер на дяловото участие: 10.00 лв.
`;
+
+const F23_ERASED = `
Заличено обстоятелство.
`;
+
+const F5_SEAT = `
Държава: БЪЛГАРИЯ Област: Пловдив, Община: Родопи Населено място: с. Марково, п.к. 4108 бул./ул. местност ЗАХАРИДЕВО № 043А Телефон: 032/901102 и 032/945149 Адрес на електронна поща: office@pimk-bg.eu
`;
+
+const F7_MANAGER = `
АНТОН ИЦКОВ ЙОРДАНОВ, Държава: БЪЛГАРИЯ
`;
+
+const field = (nameCode, htmlData, over = {}) => ({
+ nameCode,
+ htmlData,
+ fieldEntryNumber: '20130716101007',
+ fieldEntryDate: '2013-07-16T10:10:07',
+ fieldOperation: 3,
+ fieldIdent: '00190',
+ ...over,
+});
+const deedOf = (fields, over = {}) => ({
+ uic: '115536179',
+ fullName: '"ПИМК" ООД',
+ legalForm: 4,
+ sections: [{ subDeeds: [{ groups: [{ fields }] }] }],
+ ...over,
+});
+
+// ── T1 — the entity boundary ──────────────────────────────────────────────────
+test('entityBlocks splits one field into its separate registered entities', () => {
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(blocks.length, 3, 'three съдружници, three blocks');
+ assert.match(blocks[0].text, /ПИМК ХОЛДИНГ ГРУП АД/);
+ assert.match(blocks[1].text, /ПЕНКО НЕСТОРОВ НЕСТОРОВ/);
+ assert.match(blocks[2].text, /ИЛИЯН КОСТАДИНОВ ФИЛИПОВ/);
+ // No block may carry another block's name — that is the whole point.
+ assert.ok(!blocks[1].text.includes('ФИЛИПОВ'));
+ assert.ok(!blocks[2].text.includes('ПЕНКО'));
+});
+
+test('T1 — tokens from two DIFFERENT people never combine into a match', () => {
+ // „ПЕНКО … НЕСТОРОВ" and „ИЛИЯН КОСТАДИНОВ ФИЛИПОВ" are both in field 19. A declarant assembled
+ // from one person's given name and another's patronymic+surname must NOT match.
+ const frankenstein = 'ПЕНКО КОСТАДИНОВ ФИЛИПОВ';
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch(frankenstein, b.text)),
+ false,
+ 'cross-entity match — this is the libel bug',
+ );
+ // Whole-field matching is exactly what must not be done; prove the naive approach WOULD have fired,
+ // so this test cannot silently pass because the matcher is broken in some other way.
+ assert.equal(fullSubsetMatch(frankenstein, F19_THREE), true, 'naive whole-field match fires');
+});
+
+// The live register emits single-quoted attributes today, and every fixture above is verbatim from a
+// real deed. But the entity split is the ONLY thing standing between three people and one merged
+// token pool, and it must not be quiet about a markup change: if the register ever switches to
+// class="…", a quote-specific pattern stops splitting, all three owners collapse into one block, and
+// the frankenstein match above starts firing — a named public claim about a person who is not there.
+// R7's doctrine for this module is „refuse loudly, never guess"; silently mis-splitting is neither.
+const dq = (s) => s.replace(/class='([^']*)'/g, 'class="$1"');
+
+test('T1 — the entity split survives double-quoted attributes (markup-drift hardening)', () => {
+ // Strip the separators first. entityBlocks splits on BOTH and record-container precisely
+ // because either may be absent; with the present this test would pass on the rule alone
+ // and prove nothing about the quote handling it is here to pin.
+ const blocks = entityBlocks(dq(F19_THREE).replace(/]*>/gi, ''));
+ assert.equal(blocks.length, 3, 'a quote style change must not merge three owners into one block');
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch('ПЕНКО КОСТАДИНОВ ФИЛИПОВ', b.text)),
+ false,
+ 'cross-entity match under double quotes — the libel bug via markup drift',
+ );
+ assert.ok(blocks.some((b) => fullSubsetMatch('ПЕНКО НЕСТОРОВ НЕСТОРОВ', b.text)));
+});
+
+test('T1 — erasure is still detected and stripped under double-quoted attributes', () => {
+ const [block] = entityBlocks(dq(F23_ERASED));
+ assert.equal(block.erased, true);
+ assert.equal(block.text, '', 'the erasure notice must not survive as content');
+ // The strict contradiction check must not fire merely because the quote style changed.
+ assert.doesNotThrow(() => entityBlocks(dq(F23_ERASED), { strict: true }));
+});
+
+test('T1 positive control — the CORRECT declarant does match', () => {
+ // Without this, a matcher that always returns false passes every negative test above (ADR-0027).
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch('ИЛИЯН КОСТАДИНОВ ФИЛИПОВ', b.text)),
+ true,
+ );
+ assert.equal(
+ blocks.some((b) => fullSubsetMatch('Пенко Несторов Несторов', b.text)),
+ true,
+ 'case/spacing drift still matches within the right entity',
+ );
+});
+
+test('T1 — HTML entities are decoded BEFORE splitting, not after', () => {
+ // Decode-after-split leaves "-bearing names mangled; decode-before-split is the tested order.
+ const encoded = F19_THREE.replace('ПИМК ХОЛДИНГ ГРУП АД', '"ПИМК ХОЛДИНГ ГРУП" АД');
+ const blocks = entityBlocks(encoded);
+ assert.equal(blocks.length, 3);
+ assert.match(blocks[0].text, /"ПИМК ХОЛДИНГ ГРУП" АД/);
+ assert.ok(!blocks[0].text.includes('"'), 'entities must be decoded');
+});
+
+test('T1 — a corporate съдружник contributes its own tokens, not a person match', () => {
+ // „ПИМК ХОЛДИНГ ГРУП АД, ЕИК/ПИК 202294392" is a legal entity. A person whose name happens to
+ // overlap its words must not match it into existence.
+ const blocks = entityBlocks(F19_THREE);
+ assert.equal(
+ fullSubsetMatch('ПИМК ХОЛДИНГ ГРУП', blocks[0].text),
+ true,
+ 'literal overlap exists',
+ );
+ // …which is why rung 2 requires ≥3 tokens of a PERSON name; the guard lives in evidence.mjs (T2).
+});
+
+// ── erasure ───────────────────────────────────────────────────────────────────
+test('an erased entity is detected structurally and dropped from live state', () => {
+ // MEASURED: the live deed marks erasure with `erasure-text-inline` and the container carries NO
+ //
at all. An earlier note claimed the marker was `field-text--erased`; that
+ // class does not occur in the sampled deed. Both are treated as erasure so either shape is safe.
+ const blocks = entityBlocks(F23_ERASED);
+ assert.equal(blocks.length, 1);
+ assert.equal(blocks[0].erased, true);
+ const live = liveFields(deedOf([field('CR_F_23_L', F23_ERASED, { fieldOperation: 2 })]), [
+ 'CR_F_23_L',
+ ]);
+ assert.deepEqual(live, [], 'an erased field contributes no live entity');
+});
+
+test('the OTHER erasure spelling is honoured too', () => {
+ const alt = `
СТАР СОБСТВЕНИК
Заличено обстоятелство.
`;
+ assert.equal(entityBlocks(alt)[0].erased, true);
+});
+
+test('an erased-looking block carrying real content REFUSES the deed (drift alarm)', () => {
+ // R7: "erased ⇒ empty" is an empirical observation (93 of 93). If the register ever emits an erased
+ // marker beside live content, the assumption is broken and we must stop, not silently drop a real
+ // owner or silently keep a removed one.
+ const contradictory = `
ЖИВО ИМЕ ТУК
Заличено обстоятелство.
`;
+ assert.throws(() => entityBlocks(contradictory, { strict: true }), /erased.*content|drift/i);
+});
+
+test('an empty htmlData yields no entities and does not throw', () => {
+ assert.deepEqual(entityBlocks(''), []);
+ assert.deepEqual(entityBlocks(null), []);
+});
+
+// A numeric entity is attacker-shaped input in the only sense that matters here: it comes off the
+// wire, and `String.fromCodePoint` throws RangeError above U+10FFFF. That throw escapes decodeEntities,
+// escapes entityBlocks and registrySeat, and — because the crawl loop's try/catch covers only
+// JSON.parse + assertUicEcho — escapes run() and kills the process. One malformed entity in one deed
+// would end a paced crawl that has already spent its request budget. Out of range is not a person, so
+// the only defensible reading is „no character": drop it and keep parsing the rest of the entity.
+test('an out-of-range numeric entity is dropped, never thrown out of the parser', () => {
+ const overflow = F19_THREE.replace('ПЕНКО', 'ПЕНКО');
+ const blocks = entityBlocks(overflow);
+ assert.equal(blocks.length, 3, 'the deed still parses into its three entities');
+ assert.match(blocks[1].text, /ПЕНКО НЕСТОРОВ НЕСТОРОВ/, 'the surrounding name survives intact');
+ assert.ok(!blocks[1].text.includes(''), 'the escape itself does not survive as literal text');
+});
+
+test('the hex numeric form is guarded too — both decode lines, not just the decimal one', () => {
+ const overflow = F19_THREE.replace('ИЛИЯН', 'ИЛИЯН');
+ const blocks = entityBlocks(overflow);
+ assert.equal(blocks.length, 3);
+ assert.match(blocks[2].text, /ИЛИЯН КОСТАДИНОВ ФИЛИПОВ/);
+});
+
+test('an in-range numeric entity still decodes — the guard bounds, it does not disable', () => {
+ // П is „П". A guard that dropped every numeric escape would silently mangle real names.
+ const blocks = entityBlocks(F19_THREE.replace('ПЕНКО', 'ПЕНКО'));
+ assert.match(blocks[1].text, /ПЕНКО НЕСТОРОВ НЕСТОРОВ/);
+});
+
+test('registrySeat survives the same malformed entity rather than aborting the load', () => {
+ // registrySeat sits OUTSIDE the crawl loop's refuse-and-continue block (fetch-deeds.mjs), and
+ // load.mjs calls it again at decision time — so an unguarded throw here takes down both legs.
+ const d = deedOf([field('CR_F_5_L', F5_SEAT.replace('с. Марково', 'с. Марково'))]);
+ const seat = registrySeat(d);
+ assert.equal(seat.settlement, 'МАРКОВО');
+});
+
+test('liveFields keeps only the requested codes and reports entry date/number', () => {
+ const d = deedOf([
+ field('CR_F_7_L', F7_MANAGER, { nameCode: 'CR_F_7_L', fieldEntryDate: '2017-09-15T00:00:00' }),
+ field('CR_F_19_L', F19_THREE),
+ field('CR_F_5_L', F5_SEAT),
+ ]);
+ const live = liveFields(d, ['CR_F_7_L', 'CR_F_19_L']);
+ assert.deepEqual(live.map((f) => f.nameCode).sort(), ['CR_F_19_L', 'CR_F_7_L']);
+ const f19 = live.find((f) => f.nameCode === 'CR_F_19_L');
+ assert.equal(f19.entities.length, 3);
+ assert.equal(f19.entryDate, '2013-07-16');
+ assert.equal(f19.entryNumber, '20130716101007');
+ assert.equal(typeof f19.entryNumber, 'string', 'entry numbers exceed 2^53 — never a number');
+});
+
+// ── T2 — the token rule ───────────────────────────────────────────────────────
+test('personTokens keeps tokens of length ≥2 and folds case/spacing', () => {
+ assert.deepEqual(personTokens('Иван Петров Георгиев'), ['ИВАН', 'ПЕТРОВ', 'ГЕОРГИЕВ']);
+ assert.deepEqual(personTokens(' иван петров '), ['ИВАН', 'ПЕТРОВ']);
+ // An initial is not a token — „Г. И. Петров" is ONE token, so it can never reach three.
+ assert.deepEqual(personTokens('Г. И. Петров'), ['ПЕТРОВ']);
+ // A hyphenated surname is one token, not two.
+ assert.deepEqual(personTokens('Мария Иванова-Петрова'), ['МАРИЯ', 'ИВАНОВА', 'ПЕТРОВА']);
+});
+
+test('fullSubsetMatch requires EVERY declarant token, not a majority', () => {
+ const entity = 'ИЛИЯН КОСТАДИНОВ ФИЛИПОВ, Държава: БЪЛГАРИЯ';
+ assert.equal(fullSubsetMatch('ИЛИЯН КОСТАДИНОВ ФИЛИПОВ', entity), true);
+ // 2-of-3 must fail: of 301 measured matches, 46 were two-token only — the homonym risk itself.
+ assert.equal(fullSubsetMatch('ИЛИЯН КОСТАДИНОВ ПЕТРОВ', entity), false);
+ assert.equal(fullSubsetMatch('ИЛИЯН ПЕТРОВ ФИЛИПОВ', entity), false);
+});
+
+test('a token must match a WHOLE token, never a substring', () => {
+ // „ПЕТРОВ" must not be found inside „ПЕТРОВА"; that is a different person.
+ assert.equal(fullSubsetMatch('ИВАН ПЕТРОВ ГЕОРГИЕВ', 'ИВАН ПЕТРОВА ГЕОРГИЕВА'), false);
+});
+
+// ── T5 — settlement normalization ─────────────────────────────────────────────
+test('T5 — the settlement prefix is stripped only as a whole token', () => {
+ // R9: a naive prefix strip turns СОФИЯ into ОФИЯ and ГРАДЕЦ into АДЕЦ.
+ assert.equal(normalizeSettlement('гр. Русе'), 'РУСЕ');
+ assert.equal(normalizeSettlement('с. Марково'), 'МАРКОВО');
+ assert.equal(normalizeSettlement('София'), 'СОФИЯ');
+ assert.equal(normalizeSettlement('СОФИЯ'), 'СОФИЯ');
+ assert.equal(normalizeSettlement('Градец'), 'ГРАДЕЦ');
+ assert.equal(normalizeSettlement('гр.Пловдив'), 'ПЛОВДИВ');
+ assert.equal(normalizeSettlement('София (столица)'), 'СОФИЯ');
+});
+
+test('T5 — an empty settlement never equals another empty settlement', () => {
+ // „both blank ⇒ confirmed" would rubber-stamp every link with no seat data at all.
+ assert.equal(normalizeSettlement(''), '');
+ assert.equal(normalizeSettlement(null), '');
+ assert.equal(normalizeSettlement(' '), '');
+});
+
+test('registrySeat reads the „Населено място" segment and NOTHING else', () => {
+ const seat = registrySeat(deedOf([field('CR_F_5_L', F5_SEAT, { nameCode: 'CR_F_5_L' })]));
+ assert.equal(seat.settlement, 'МАРКОВО');
+ assert.equal(seat.entryDate, '2013-07-16');
+ // ADR-0010 item 3: the parser never returns an address, phone, e-mail or website — and the deed
+ // demonstrably carries all four.
+ const blob = JSON.stringify(seat);
+ for (const leak of ['ЗАХАРИДЕВО', '032/901102', 'office@pimk-bg.eu', 'п.к.', '4108', 'Родопи'])
+ assert.ok(!blob.includes(leak), `seat must not carry ${leak}`);
+});
+
+// ── T3 — the joint-stock bar ──────────────────────────────────────────────────
+test('T3 — the legal-form verdict is a UNION of the code and the ЗТРРЮЛНЦ suffix', () => {
+ const jointByCode = registryLegalForm(deedOf([], { legalForm: 5, fullName: 'НЕЩО СИ' }));
+ assert.equal(jointByCode.verdict, 'joint_stock');
+
+ // An UNKNOWN code must not fall through: the suffix decides, and if it cannot, we withhold.
+ const unknownButEad = registryLegalForm(deedOf([], { legalForm: 99, fullName: '"ГАМА" ЕАД' }));
+ assert.equal(unknownButEad.verdict, 'joint_stock', 'barred by the mandated suffix');
+
+ const unknownButEood = registryLegalForm(deedOf([], { legalForm: 99, fullName: '"БЕТА" ЕООД' }));
+ assert.equal(unknownButEood.verdict, 'closely_held', 'the bar is not blanket');
+
+ const unknownAndUnreadable = registryLegalForm(deedOf([], { legalForm: 99, fullName: 'НЕЩО' }));
+ assert.equal(unknownAndUnreadable.verdict, 'unknown', 'unknown withholds — it never publishes');
+});
+
+test('T3 — КДА is barred (it is not in the existing closelyHeldForm token list)', () => {
+ assert.equal(
+ registryLegalForm(deedOf([], { legalForm: 99, fullName: '"X" КДА' })).verdict,
+ 'joint_stock',
+ );
+ assert.equal(
+ registryLegalForm(deedOf([], { legalForm: 6, fullName: 'X' })).verdict,
+ 'joint_stock',
+ );
+});
+
+test('T3 — a real ООД deed reads as closely held, by code AND by suffix', () => {
+ const v = registryLegalForm(deedOf([]));
+ assert.equal(v.code, 4);
+ assert.equal(v.verdict, 'closely_held');
+ assert.equal(v.suffixVerdict, 'closely_held', 'fullName carries the form: "ПИМК" ООД');
+});
+
+// ── T7 — the UIC echo ─────────────────────────────────────────────────────────
+test('T7 — a deed whose UIC does not echo the request is REFUSED', () => {
+ // R8: ЕИК leading zeros are significant (public bodies are exactly 000…). If anything on the path
+ // rewrites the identifier, this is the rail that catches it before a claim is made about the
+ // wrong company.
+ assert.doesNotThrow(() => assertUicEcho(deedOf([]), '115536179'));
+ assert.throws(() => assertUicEcho(deedOf([]), '000696327'), /uic|echo/i);
+ assert.throws(() => assertUicEcho(deedOf([], { uic: '696327' }), '000696327'), /uic|echo/i);
+ assert.throws(() => assertUicEcho(deedOf([], { uic: null }), '115536179'), /uic|echo/i);
+});
+
+// ── the refutation input ──────────────────────────────────────────────────────
+test('latestOwnershipEntryDate ignores ERASED ownership fields', () => {
+ // The trap, present in the first company sampled: CR_F_23_L is live in the current deed, dated
+ // 2013-07-16, and contains only „Заличено обстоятелство.". Read naively it becomes „latest
+ // ownership entry: 2013-07-16" and can refute a link it knows nothing about.
+ const d = deedOf([
+ field('CR_F_19_L', F19_THREE, { fieldEntryDate: '2011-05-02T00:00:00' }),
+ field('CR_F_23_L', F23_ERASED, { fieldEntryDate: '2013-07-16T10:10:07', fieldOperation: 2 }),
+ ]);
+ assert.equal(latestOwnershipEntryDate(d), '2011-05-02', 'the erased 2013 entry must not count');
+});
+
+test('latestOwnershipEntryDate is null when no live ownership field survives', () => {
+ const d = deedOf([field('CR_F_23_L', F23_ERASED, { fieldOperation: 2 })]);
+ assert.equal(latestOwnershipEntryDate(d), null);
+});
+
+// The erasure-notice strip used an unbounded lazy `.*?`, which backtracks quadratically when the opening
+// div is never closed — each opening restarts a scan to end-of-input. Measured on that shape: 34K→3.3ms,
+// 68K→13.6ms, 136K→53.8ms, 272K→240ms, 1M→4.0s (×4 per doubling). The parser runs against whatever the
+// register returns, so that is remote-controlled CPU on a paced crawl with a per-request budget.
+test('adversarial unclosed markup parses in linear time, not quadratically', () => {
+ // ~1 MB of unclosed erasure openings — the exact shape that triggers the backtracking.
+ const doc = '
z'.repeat(32_000);
+ const t = process.hrtime.bigint();
+ entityBlocks(doc);
+ const ms = Number(process.hrtime.bigint() - t) / 1e6;
+ // Bounded measures ~190ms here and unbounded ~4000ms, so 1500ms separates them with ~8× headroom
+ // over the bounded path — wide enough not to flake on a loaded runner, tight enough to catch a
+ // reintroduced `.*?`.
+ assert.ok(ms < 1500, `entityBlocks took ${ms.toFixed(0)}ms on 1MB of unclosed markup`);
+});
+
+test('an over-long erasure notice still marks the block erased — the bound cannot leak a live owner', () => {
+ // If the notice exceeds the bound the regex simply does not strip it. `erased` is decided separately
+ // by ERASED_MARKER, so the block is still erased and liveFields still drops it: the failure mode of
+ // the bound is a noisier block, never a resurrected owner.
+ const long = `
${'Заличено. '.repeat(400)}
`;
+ const [block] = entityBlocks(long);
+ assert.equal(block.erased, true);
+ assert.deepEqual(
+ liveFields(deedOf([field('CR_F_19_L', long)]), ['CR_F_19_L']),
+ [],
+ 'an erased block contributes no live entity regardless of its notice length',
+ );
+});
+
+// JOINT_SUFFIX here and JOINT_STOCK in scripts/cacbg/classify.mjs are the SAME rule — which legal-form
+// suffixes mark a share-issuing company — held in two places because the TR parser cannot import out of
+// scripts/cacbg/ without closing a cacbg↔tr cycle. The drift this risks has already happened once: 5f64f5c
+// added КДА to classify.mjs while deed.mjs's comment still asserted it was absent there. A prose „keep
+// these in step" note does not keep anything in step; this does.
+test('the joint-stock suffix rule is identical in the TR parser and the classifier', async () => {
+ const { JOINT_STOCK } = await import('../cacbg/classify.mjs');
+ assert.equal(JOINT_SUFFIX.source, JOINT_STOCK.source, 'the two patterns have diverged');
+ assert.equal(JOINT_SUFFIX.flags, JOINT_STOCK.flags, 'the two patterns have diverged in flags');
+ // Behavioural pin as well as textual: identical sources with different behaviour is impossible, but a
+ // future refactor could legitimately change BOTH sources while breaking one. These are the forms the
+ // bar exists for — every one must be caught by both, or a joint-stock parcel publishes as ownership.
+ for (const name of ['ТРЕЙС ГРУП ХОЛД АД', 'НЕЩО ЕАД', 'ФОНД АДСИЦ', 'НЕЩО КДА']) {
+ assert.equal(JOINT_SUFFIX.test(name), true, name);
+ assert.equal(JOINT_STOCK.test(name), true, name);
+ }
+ for (const name of ['АЛФА СТРОЙ ООД', 'БЕТА ЕООД', 'АД ГРУП ООД']) {
+ assert.equal(JOINT_SUFFIX.test(name), false, name);
+ assert.equal(JOINT_STOCK.test(name), false, name);
+ }
+});
diff --git a/scripts/tr/eik.mjs b/scripts/tr/eik.mjs
new file mode 100644
index 00000000..5a4d5b12
--- /dev/null
+++ b/scripts/tr/eik.mjs
@@ -0,0 +1,61 @@
+// ЕИК validity — the Node twin of the rule that already lives in SQL.
+//
+// `eik_valid` in scripts/normalize-raw.sql decides which bidders get an ЕИК-keyed identity at all, so
+// it defines the ЕИК space the whole matcher works in. Until now that rule was callable ONLY from
+// SQL, which is why the registry leg needs this: the crawler must decide, in Node, whether a code is
+// worth a lookup. The two implementations are pinned against each other by a test that lifts the CASE
+// expression straight out of the .sql file and runs both over the same values (eik.test.mjs) — a copy
+// of the rule would drift from the thing it is copying.
+//
+// The rule (ЗТРРЮЛНЦ / БУЛСТАТ):
+// 9-digit — weight digits 1..8 by 1..8; control = sum % 11. If that is 10, re-weight by 3..10;
+// a second 10 becomes 0. Digit 9 must equal the control.
+// 13-digit — the leading 9 must themselves be a valid 9-digit ЕИК, then weight digits 9..12 by
+// 2,7,3,5 (fallback 4,9,5,7; a second 10 becomes 0). Digit 13 must equal that control.
+//
+// Everything here is string-in, string-out. An ЕИК is an identifier, not a number: public bodies carry
+// codes of exactly the `000…` shape, and a numeric round-trip drops the leading zeros and silently
+// turns one company's identifier into another's.
+
+/** Weighted control digit over `digits`, with the standard second-pass fallback. @returns {number} */
+function control(digits, primary, fallback) {
+ const sum = (ws) => ws.reduce((acc, w, i) => acc + w * digits[i], 0) % 11;
+ const first = sum(primary);
+ if (first < 10) return first;
+ const second = sum(fallback);
+ return second < 10 ? second : 0;
+}
+
+/**
+ * Is this a structurally valid ЕИК (9 or 13 digits, correct control digit)?
+ * Service codes (`000000000`, `0000000000000`) are rejected outright, matching the SQL: they pass the
+ * arithmetic but are placeholders, and letting them through collapsed unrelated foreign suppliers onto
+ * one node (#195).
+ * @param {unknown} eik @returns {boolean}
+ */
+export function eikChecksumValid(eik) {
+ const s = String(eik ?? '');
+ if (s === '000000000' || s === '0000000000000') return false;
+ if (!/^\d+$/.test(s)) return false;
+ if (s.length !== 9 && s.length !== 13) return false;
+
+ const d = [...s].map(Number);
+ const c9 = control(d.slice(0, 8), [1, 2, 3, 4, 5, 6, 7, 8], [3, 4, 5, 6, 7, 8, 9, 10]);
+ if (c9 !== d[8]) return false;
+ if (s.length === 9) return true;
+
+ const c13 = control(d.slice(8, 12), [2, 7, 3, 5], [4, 9, 5, 7]);
+ return c13 === d[12];
+}
+
+/**
+ * Normalise a raw ЕИК string to digits only, or null when it is not one.
+ * Mirrors the SQL's `eik_clean`: strips a leading „ЕИК " label and surrounding whitespace, and does
+ * NOT otherwise repair the value. Validity is a separate question — `eikChecksumValid`.
+ * @param {unknown} raw @returns {string|null}
+ */
+export function normalizeEik(raw) {
+ const s = String(raw ?? '').trim();
+ const stripped = (s.startsWith('ЕИК ') ? s.slice(4) : s).trim();
+ return /^\d{9}$|^\d{13}$/.test(stripped) ? stripped : null;
+}
diff --git a/scripts/tr/eik.test.mjs b/scripts/tr/eik.test.mjs
new file mode 100644
index 00000000..7a80a191
--- /dev/null
+++ b/scripts/tr/eik.test.mjs
@@ -0,0 +1,135 @@
+// node:test — the ЕИК checksum, and its PARITY with the SQL that already owns this rule.
+//
+// Why parity matters: `eik_valid` in scripts/normalize-raw.sql decides which bidders get an ЕИК-keyed
+// identity at all, so it defines the ЕИК space the whole matcher works in. A JS twin that disagrees
+// would silently accept a code the pipeline rejects (or vice versa) and the registry lookup would be
+// made against an entity the rest of the system does not believe exists. The parity test below runs
+// both implementations over the same values through node:sqlite, so they cannot drift apart.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { DatabaseSync } from 'node:sqlite';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { eikChecksumValid, normalizeEik } from './eik.mjs';
+
+// Real ЕИК, verifiable against the public registers — the only external anchor this rule has.
+const REAL = [
+ '000696327', // Община София
+ '831661388', // Министерство на регионалното развитие
+ '115536179', // „ПИМК" ООД
+ '207695026', // „Профит Екстра" ЕООД
+];
+
+test('accepts real 9-digit ЕИК', () => {
+ for (const e of REAL) assert.equal(eikChecksumValid(e), true, e);
+});
+
+test('rejects a wrong-digit twin of every real ЕИК', () => {
+ // The failure this rule exists to stop (#195): a typo twin passing as a distinct real company and
+ // collapsing unrelated suppliers onto one node. Mutating the LAST digit must always break it.
+ for (const e of REAL) {
+ const bad = e.slice(0, 8) + ((Number(e[8]) + 1) % 10);
+ assert.equal(eikChecksumValid(bad), false, `${bad} (twin of ${e})`);
+ }
+});
+
+test('rejects service codes, wrong lengths and non-digits', () => {
+ for (const bad of [
+ '000000000',
+ '0000000000000',
+ '',
+ null,
+ undefined,
+ '12345678', // 8
+ '1234567890', // 10 — never a valid ЕИК length, which is what makes the ЕГН guard sound
+ '11553617x',
+ '115 536 179',
+ ]) {
+ assert.equal(eikChecksumValid(bad), false, String(bad));
+ }
+});
+
+test('13-digit branch requires BOTH the 9-digit prefix and the 13th control digit', () => {
+ // A клон/поделение code: the leading 9 must themselves be a valid ЕИК, then weights 2,7,3,5.
+ const base = '115536179';
+ const valid13 = thirteen(base, '001');
+ assert.equal(valid13.length, 13, valid13);
+ assert.equal(eikChecksumValid(valid13), true, valid13);
+ // break the 13th digit
+ const broken = valid13.slice(0, 12) + ((Number(valid13[12]) + 1) % 10);
+ assert.equal(eikChecksumValid(broken), false, broken);
+ // a valid 13th control over an INVALID 9-prefix must still fail — both halves are load-bearing
+ assert.equal(eikChecksumValid(thirteen('115536170', '001')), false);
+});
+
+// Build a 13-digit ЕИК with a correct final control digit: 9-digit prefix + 3 free digits + control.
+// The 2,7,3,5 weights run over positions 9..12 — that is the prefix's OWN control digit plus the three
+// free ones — and position 13 is the result.
+function thirteen(prefix9, three) {
+ const s = prefix9 + three;
+ const d = [...s].map(Number);
+ const w = (ws) => ws.reduce((a, x, i) => a + x * d[8 + i], 0) % 11;
+ let c = w([2, 7, 3, 5]);
+ if (c === 10) {
+ c = w([4, 9, 5, 7]);
+ if (c === 10) c = 0;
+ }
+ return s + c;
+}
+
+test('normalizeEik strips the „ЕИК " prefix and surrounding whitespace, like the SQL does', () => {
+ assert.equal(normalizeEik('ЕИК 115536179'), '115536179');
+ assert.equal(normalizeEik(' 115536179 '), '115536179');
+ assert.equal(normalizeEik('115536179'), '115536179');
+ assert.equal(normalizeEik('не е ЕИК'), null);
+ assert.equal(normalizeEik(null), null);
+});
+
+test('normalizeEik preserves leading zeros (public bodies are exactly 000…)', () => {
+ // Lose these and the crawler fetches a DIFFERENT company's deed. String in, string out — never a
+ // numeric round-trip.
+ assert.equal(normalizeEik('000696327'), '000696327');
+ assert.equal(typeof normalizeEik('000696327'), 'string');
+});
+
+// ── the anti-drift pin ────────────────────────────────────────────────────────
+test('JS twin agrees with normalize-raw.sql eik_valid on every value', () => {
+ const sql = readFileSync(fileURLToPath(new URL('../normalize-raw.sql', import.meta.url)), 'utf8');
+ const expr = extractEikValidExpression(sql);
+
+ const db = new DatabaseSync(':memory:');
+ db.exec('CREATE TABLE probe (eik_clean TEXT)');
+ const ins = db.prepare('INSERT INTO probe VALUES (?)');
+
+ const cases = [...REAL, ...REAL.map((e) => e.slice(0, 8) + ((Number(e[8]) + 1) % 10))];
+ for (const e of REAL) cases.push(thirteen(e, '001'), thirteen(e, '002'));
+ // Every 9-digit code over a fixed prefix — exercises BOTH weight passes incl. the second-10 → 0 fallback.
+ for (let i = 0; i < 100; i++) cases.push('2011223' + String(i).padStart(2, '0'));
+ for (const bad of ['000000000', '0000000000000', '12345678', '1234567890', '11553617x'])
+ cases.push(bad);
+
+ for (const c of cases) ins.run(c);
+ const rows = db.prepare(`SELECT eik_clean AS e, ${expr} AS v FROM probe`).all();
+ db.close();
+
+ assert.ok(rows.length >= 120, `expected a broad probe set, got ${rows.length}`);
+ for (const { e, v } of rows) {
+ assert.equal(
+ eikChecksumValid(e),
+ v === 1,
+ `disagreement on ${JSON.stringify(e)} (SQL said ${v})`,
+ );
+ }
+});
+
+/**
+ * Lift the `CASE … END AS eik_valid` expression straight out of normalize-raw.sql, so the test
+ * compares against the FILE and not a copy of it. A copy would drift with the thing it is pinning.
+ */
+function extractEikValidExpression(sql) {
+ const start = sql.indexOf(' CASE\n WHEN eik_clean IS NULL');
+ assert.ok(start > 0, 'eik_valid CASE not found in normalize-raw.sql — the pin lost its anchor');
+ const end = sql.indexOf('AS eik_valid', start);
+ assert.ok(end > start, 'eik_valid terminator not found');
+ return sql.slice(start, end);
+}
diff --git a/scripts/tr/evidence.mjs b/scripts/tr/evidence.mjs
new file mode 100644
index 00000000..56bc01ef
--- /dev/null
+++ b/scripts/tr/evidence.mjs
@@ -0,0 +1,306 @@
+// The evidence ladder (issue #279 §5, ADR-0033 decision 1). Pure: deed in, verdict out. Zero network.
+//
+// Six outcomes, FIRST MATCH WINS:
+// 1 bar_joint_stock АД / ЕАД / КДА — never published, whatever follows
+// 2 document the declarant's full name is in a live CR_F_7/18/19/23_L entity
+// 3 confirmed declared seat == registered seat, or the declarant wrote the ЕИК
+// 4 refuted own stake only: absent from live state, and the live ownership record
+// predates the declared period — the register covers it and does not name them
+// 5 unknown everything else — held
+// 6 outside_tr not in the register at all (ДЗЗД, БУЛСТАТ associations) — held
+//
+// WHAT THIS ESTABLISHES, precisely: the identity of the COMPANY — that the company behind the declared
+// name is the same legal entity as the winner we matched. It does NOT establish that the official owns
+// it; that claim comes from their own filed declaration and is not a heuristic at all. The failure mode
+// of a wrong match is therefore not an invented ownership claim but a real official attached to the
+// WRONG company's ЕИК, contracts and money. Still a false public statement about a named person, which
+// is why rung 2 requires a full three-token subset match inside a single registry entity, and why the
+// filters that can only withhold are kept (ADR-0033 decision 2).
+
+import {
+ liveFields,
+ fullSubsetMatch,
+ personTokens,
+ normalizeSettlement,
+ registrySeat,
+ registryLegalForm,
+ latestOwnershipEntryDate,
+ OWNERSHIP_FIELDS,
+ MANAGER_FIELD,
+ ROLE_FIELDS,
+} from './deed.mjs';
+
+/**
+ * Version of the RULES, not of the code. §8's monotonicity gate keys on this: a previously published
+ * link disappearing under an UNCHANGED rules version is a hard finding; under a changed one it is an
+ * expected diff. Bump it whenever a rung's meaning changes.
+ */
+export const RULES_VERSION = 'tr-rules-1';
+
+/** Rung 2 needs a real three-part Bulgarian name (ЗГР чл. 9). Two tokens is the homonym risk itself. */
+const MIN_NAME_TOKENS = 3;
+
+/**
+ * The CLOSED vocabulary a sealed `matched_fact` may take: `seat:`, `role:owner:`,
+ * `role:manager:`, or `eik`. It must NEVER carry the matched NAME — the deed's names are read
+ * only to produce a boolean and never leave git-ignored scratch (#279 §9, ADR-0033 decision 5).
+ *
+ * The seat token bound is the whole rail. `seat:` is a legitimate prefix, so an unbounded settlement
+ * pattern admits `seat:ИВАН ПЕТРОВ ГЕОРГИЕВ` — a full three-part Bulgarian name (ЗГР чл. 9) wearing an
+ * allowed prefix, which is exactly the value a mis-split of the seat field would produce and exactly
+ * what the rail exists to reject. A settlement is one or two tokens („СОФИЯ", „ВЕЛИКО ТЪРНОВО",
+ * „ГЕНЕРАЛ ТОШЕВО"); a three-part name is exactly three. Bounding at two separates them cleanly, and a
+ * rarer 3-token seat stops the run for a human rather than publishing — the correct direction for a rail
+ * whose failure mode is putting somebody's name on a served column.
+ *
+ * Defined ONCE and consumed by both the writer (load.mjs) and the audit, so the two cannot drift into
+ * a state where the gate permits what the writer emits.
+ */
+export const MATCHED_FACT_RE =
+ /^(?:seat:\p{Lu}[\p{Lu}-]*(?: \p{Lu}[\p{Lu}-]*)?|role:(?:owner|manager):CR_F_\d+[a-z]?_L|eik)$/u;
+
+/** True when `fact` is a member of the closed vocabulary. `null` is legal — a rung may match no fact. */
+export function isSealedFact(fact) {
+ return fact == null || MATCHED_FACT_RE.test(String(fact));
+}
+
+// Court-registered companies were re-registered into the Търговски регистър in a single administrative
+// push, which flattened their entry dates into this window. „Strictly before the declared period"
+// certifies nothing when the date is an artefact of the migration rather than of the ownership, so the
+// refutation rung is suppressed inside it (R13). A suppressed refutation falls through to `unknown` —
+// held, not published, which is the safe direction.
+const REREGISTRATION_START = '2011-01-01';
+const REREGISTRATION_END = '2012-12-31';
+
+/**
+ * Find the declarant inside the live entities of the given field codes.
+ * Matching happens per ENTITY — never against a whole field — because one field routinely holds
+ * several people and combining tokens across them is the libel bug.
+ * @returns {{nameCode:string, entryNumber:string|null, entryDate:string|null}|null}
+ */
+function findPerson(deed, name, nameCodes) {
+ for (const f of liveFields(deed, nameCodes)) {
+ for (const entity of f.entities) {
+ if (fullSubsetMatch(name, entity)) {
+ return { nameCode: f.nameCode, entryNumber: f.entryNumber, entryDate: f.entryDate };
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * The registered seat, when it matches one THIS person declared for THIS company and was in force for the
+ * declared period. Shared by rung 3 (which publishes on it) and rung 2's company-identity corroborator.
+ *
+ * R10: seats move. A company that relocated INTO the declared settlement afterwards would confirm falsely,
+ * so the registered seat must predate the period.
+ *
+ * A null `firstDeclaredYear` FAILS the guard rather than skipping it. `load.mjs` passes null whenever no
+ * history row carried a parseable year, and an unknown year is not a satisfied temporal test — it is the
+ * absence of one. Reading it as „covers the period" made the weakest rung the only one with no temporal
+ * check, on exactly the links where we know least, and rung 4 already refuses to run without a year on the
+ * same ground. The undated-SEAT leg is different and stays: a seat with no entry date is the ordinary shape
+ * for a company that never moved, and it is checkable — a known year is still on the other side.
+ *
+ * @returns {{settlement:string, entryDate:string|null}|null}
+ */
+function matchDeclaredSeat(deed, declaredSeats, firstDeclaredYear) {
+ const seat = registrySeat(deed);
+ // Empty NEVER matches — otherwise every link with no seat data on either side rubber-stamps itself.
+ if (seat.settlement === '') return null;
+ if (firstDeclaredYear == null) return null;
+ if (seat.entryDate != null && seat.entryDate > `${firstDeclaredYear}-12-31`) return null;
+ const declared = declaredSeats.map(normalizeSettlement).filter((s) => s !== '');
+ return declared.includes(seat.settlement) ? seat : null;
+}
+
+/**
+ * Decide the evidence for one link.
+ *
+ * @param {object} input
+ * @param {object|null} input.deed parsed deed JSON; null only when `outsideTr`
+ * @param {boolean} [input.outsideTr] the ЕИК is not in the register at all
+ * @param {string} input.declarantName the office-holder's name as filed
+ * @param {string[]} [input.declaredSeats] seats declared BY THIS PERSON FOR THIS COMPANY only —
+ * 4.9% of company-name keys carry more than one distinct
+ * declared seat, so a company-only key would let one
+ * person's seat confirm another person's link
+ * @param {boolean} [input.declaredEik] the declarant wrote the ЕИК in the declaration
+ * @param {number|null} [input.firstDeclaredYear]
+ * @param {'self'|'family'} [input.scope]
+ * @param {boolean} [input.nameGloballyUnique] AND-gate on the WEAKEST rung only
+ * @param {boolean} [input.companyNameDistinctive] the declared фирма is unlikely to have a national
+ * twin. Gates an UNCORROBORATED rung 2 (ADR-0035).
+ * Defaults to FALSE: a caller that forgets it withholds.
+ * @returns {{kind:string, publishable:boolean, registryRole:string|null, matchedFact:string|null,
+ * entryNumber:string|null, entryDate:string|null, rulesVersion:string,
+ * shortName:boolean, latinInName:boolean}}
+ */
+export function evidenceVerdict(input) {
+ const {
+ deed,
+ outsideTr = false,
+ declarantName,
+ declaredSeats = [],
+ declaredEik = false,
+ firstDeclaredYear = null,
+ scope = 'self',
+ nameGloballyUnique = true,
+ // Fail-CLOSED, unlike `nameGloballyUnique` above. That one's permissive default is bounded — it gates
+ // only the weakest rung. This one gates the PRIMARY publishing rung, so a caller that forgets to pass
+ // it must withhold rather than publish a claim naming a real person against a company we did not
+ // establish. There is exactly one production caller (load.mjs) and it passes it explicitly.
+ companyNameDistinctive = false,
+ } = input;
+
+ const tokens = personTokens(declarantName);
+ const telemetry = {
+ rulesVersion: RULES_VERSION,
+ // Counted, not silently dropped: a refusal we cannot see is a recall hole nobody can size.
+ shortName: tokens.length < MIN_NAME_TOKENS,
+ latinInName: /[A-Za-z]/.test(String(declarantName ?? '')),
+ };
+ const verdict = (kind, publishable, extra = {}) => ({
+ kind,
+ publishable,
+ registryRole: null,
+ matchedFact: null,
+ entryNumber: null,
+ entryDate: null,
+ ...telemetry,
+ ...extra,
+ });
+
+ if (outsideTr) return verdict('outside_tr', false);
+ if (deed == null) {
+ // Fail closed and loudly. A missing deed quietly downgraded to „unknown" is indistinguishable
+ // from a real hold, and hides a cache gap that should stop the run.
+ throw new Error('evidenceVerdict: deed is required unless outsideTr is set');
+ }
+
+ // ── rung 1 ──────────────────────────────────────────────────────────────────
+ // A union of the numeric code and the ЗТРРЮЛНЦ suffix; either saying joint-stock bars the link, and
+ // neither able to say means we withhold rather than guess.
+ const form = registryLegalForm(deed);
+ if (form.verdict === 'joint_stock') return verdict('bar_joint_stock', false);
+ if (form.verdict === 'unknown') return verdict('unknown', false);
+
+ // The registered seat, matched against what THIS person declared for THIS company, with R10's temporal
+ // guard applied. Computed once and consumed by two rungs: rung 3 publishes „Потвърдено" on it, and rung 2
+ // uses it as a COMPANY-IDENTITY corroborator. One implementation, because two copies of "what counts as a
+ // seat match" would eventually disagree about which links may be published.
+ const matchedSeat = matchDeclaredSeat(deed, declaredSeats, firstDeclaredYear);
+
+ // ── rung 2 ──────────────────────────────────────────────────────────────────
+ // Only a full three-token name may assert. A Latin homoglyph makes the name a non-match rather than
+ // a false match — company-name-key.ts's posture, applied to people.
+ //
+ // The company gate (ADR-0035). A name match proves someone with these three tokens is registered in the
+ // company we LOOKED UP — never that this is the company the official declared. `resolveEntity` picks the
+ // sole WINNER holding the declared name and `nameGloballyUnique` ranges over bidders only, so an official
+ // whose real company never bid resolves to a same-named winner, and a homonym in that winner's deed
+ // completes a link false in both halves. Before rung 2 may assert, something other than the фирма must
+ // say the company is the declared one:
+ // • the declarant wrote the ЕИК — the national identifier resolves it outright (ADR-0028); or
+ // • the declared seat matches the registered one — a twin in another town is excluded; or
+ // • the фирма is distinctive enough that a national twin is improbable in the first place.
+ // The third is a bound, not a proof, and it is COUNTED (`documentUncorroborated`) so F8 can decide from
+ // the measured residual whether to tighten to the first two. Strict corroboration was the alternative;
+ // declared seats exist only on the ООД/ЕООД table of asset declarations, so its recall cost cannot be
+ // known before that measurement.
+ const companyCorroborated = declaredEik || matchedSeat != null;
+ const eligibleForDocument = !telemetry.shortName && !telemetry.latinInName;
+ if (eligibleForDocument) {
+ const owner = findPerson(deed, declarantName, OWNERSHIP_FIELDS);
+ const manager = owner ? null : findPerson(deed, declarantName, [MANAGER_FIELD]);
+ const hit = owner ?? manager;
+ if (hit && !companyCorroborated && !companyNameDistinctive) {
+ // A DISTINCT withholding kind, not a fall-through to `unknown`. „We matched a person but could not
+ // establish the company" and „we matched nothing" are different facts about a link, and the review
+ // queue (which is sealed for held links precisely to be reviewable) has to be able to tell them
+ // apart. It never publishes, and it carries no role or fact — asserting either would leak the very
+ // claim the rung just refused to make.
+ return verdict('document_uncorroborated', false);
+ }
+ if (owner) {
+ return verdict('document', true, {
+ registryRole: 'owner',
+ matchedFact: `role:owner:${owner.nameCode}`,
+ entryNumber: owner.entryNumber,
+ entryDate: owner.entryDate,
+ });
+ }
+ if (manager) {
+ return verdict('document', true, {
+ registryRole: 'manager',
+ matchedFact: `role:manager:${manager.nameCode}`,
+ entryNumber: manager.entryNumber,
+ entryDate: manager.entryDate,
+ });
+ }
+ }
+
+ // ── rung 3 ──────────────────────────────────────────────────────────────────
+ // The weakest publishing rung, so it carries the extra AND-gate: a nationally shared company name
+ // cannot ride it (ADR-0017's holding, carried forward). The stronger „Документ" rung above is not
+ // gated — the register named this person in THIS company, which makes the name key moot.
+ // The declared-ЕИК leg is NOT name-gated. ADR-0028: the ЕИК is the identity, not the name, so it
+ // resolves the company deterministically even behind a nationally shared фирма — which is exactly the
+ // case ADR-0017 was written about. Gating it on name uniqueness would discard the strongest
+ // identifier we have precisely where it is most needed.
+ if (declaredEik) return verdict('confirmed', true, { matchedFact: 'eik' });
+
+ // The SEAT leg is name-gated, and only this one. ADR-0017's holding carried forward: a name shared by
+ // two ЕИК cannot support a name-derived identity claim. The seat still rescues a GENERIC name — that
+ // is the whole point of the rung — it just cannot rescue a NATIONALLY SHARED one.
+ if (nameGloballyUnique && matchedSeat != null) {
+ return verdict('confirmed', true, {
+ matchedFact: `seat:${matchedSeat.settlement}`,
+ entryDate: matchedSeat.entryDate,
+ });
+ }
+
+ // ── rung 4 ──────────────────────────────────────────────────────────────────
+ // OWN stakes only. For a family stake the registered owner is the relative, whose name we neither
+ // store nor check, so absence of the OFFICIAL from the deed is evidence of nothing. An early branch,
+ // not a caller convention.
+ if (scope === 'self' && firstDeclaredYear != null) {
+ const stillPresent = findPerson(deed, declarantName, ROLE_FIELDS);
+ const latest = latestOwnershipEntryDate(deed);
+ const inRereg =
+ latest != null && latest >= REREGISTRATION_START && latest <= REREGISTRATION_END;
+ if (!stillPresent && latest != null && !inRereg && latest < `${firstDeclaredYear}-01-01`) {
+ return verdict('refuted', false, { entryDate: latest });
+ }
+ }
+
+ // ── rung 5 ──────────────────────────────────────────────────────────────────
+ return verdict('unknown', false);
+}
+
+/**
+ * Reconcile a DECLARED termination against the live deed (#279 §7).
+ *
+ * „Terminated" is an inference from silence — the commonest cause is a finished mandate, not a sale —
+ * so ADR-0021 E11's withdrawal is checked against the register before it takes effect.
+ *
+ * Phase 1 uses `terminated` ONLY. `label` is computed but deliberately not rendered: „и към днешна
+ * дата" asserts a present tense about a named person on evidence whose freshness is bounded by the
+ * cache refresh cycle, and it is deferred behind an LIA addendum (ADR-0033 decision 4).
+ *
+ * @returns {{terminated:boolean, label:'owner_today'|'manager_today'|null}}
+ */
+export function reconcileTermination({ deed, declarantName, scope = 'self' }) {
+ // Family first, structurally: there is nothing to look for, and looking would be an attempt to
+ // identify the relative.
+ if (scope !== 'self' || deed == null) return { terminated: true, label: null };
+
+ if (findPerson(deed, declarantName, OWNERSHIP_FIELDS)) {
+ return { terminated: false, label: 'owner_today' };
+ }
+ if (findPerson(deed, declarantName, [MANAGER_FIELD])) {
+ return { terminated: true, label: 'manager_today' };
+ }
+ return { terminated: true, label: null };
+}
diff --git a/scripts/tr/evidence.test.mjs b/scripts/tr/evidence.test.mjs
new file mode 100644
index 00000000..d3f71085
--- /dev/null
+++ b/scripts/tr/evidence.test.mjs
@@ -0,0 +1,564 @@
+// node:test — the evidence ladder (ADR-0033 decision 1). Pure: deed in, verdict out.
+//
+// Six outcomes, first match wins. What each rung is allowed to CONCLUDE is the whole subject:
+// the registry proves the identity of the COMPANY, never that the official owns it — the ownership
+// claim comes from the official's own filed declaration. So a wrong match here does not invent an
+// ownership claim, it attaches a real official to the wrong company's ЕИК, contracts and money.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ RULES_VERSION,
+ evidenceVerdict,
+ reconcileTermination,
+ MATCHED_FACT_RE,
+ isSealedFact,
+} from './evidence.mjs';
+
+const container = (t) =>
+ `
`;
+
+const fld = (nameCode, htmlData, entryDate = '2011-05-02T00:00:00') => ({
+ nameCode,
+ htmlData,
+ fieldEntryNumber: '20110502101007',
+ fieldEntryDate: entryDate,
+ fieldOperation: 3,
+});
+const deed = (fields, over = {}) => ({
+ uic: '201122335',
+ fullName: '"АЛФА СТРОЙ" ООД',
+ legalForm: 4,
+ sections: [{ subDeeds: [{ groups: [{ fields }] }] }],
+ ...over,
+});
+
+const OWNER_DEED = deed([
+ fld('CR_F_19_L', joined('ИВАН ПЕТРОВ ТЕСТОВ, Държава: БЪЛГАРИЯ', 'МАРИЯ СТОЯНОВА ИВАНОВА')),
+ fld('CR_F_5_L', container('Държава: БЪЛГАРИЯ Населено място: гр. Пловдив, п.к. 4000')),
+]);
+
+const base = {
+ deed: OWNER_DEED,
+ declarantName: 'Иван Петров Тестов',
+ declaredSeats: [],
+ declaredEik: false,
+ firstDeclaredYear: 2021,
+ scope: 'self',
+ nameGloballyUnique: true,
+ // The company was resolved by a name unlikely to have a national twin. The rung-2 tests below are about
+ // NAME matching inside a deed, so they hold this dimension fixed; the gate itself is tested separately.
+ companyNameDistinctive: true,
+};
+
+test('RULES_VERSION is a stable, non-empty identifier — §8 hangs off it', () => {
+ assert.equal(typeof RULES_VERSION, 'string');
+ assert.ok(RULES_VERSION.length > 0);
+});
+
+// ── rung 1: the joint-stock bar wins over everything ──────────────────────────
+test('rung 1 — a joint-stock company is barred even when the person IS in the deed', () => {
+ const ad = deed([fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))], {
+ legalForm: 5,
+ fullName: '"ГАМА ИНВЕСТ" АД',
+ });
+ const v = evidenceVerdict({ ...base, deed: ad });
+ assert.equal(v.kind, 'bar_joint_stock');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 1 — an UNKNOWN legal form withholds; it never falls through to a lower rung', () => {
+ const odd = deed([fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))], {
+ legalForm: 99,
+ fullName: 'НЕЩО БЕЗ ФОРМА',
+ });
+ const v = evidenceVerdict({ ...base, deed: odd });
+ assert.equal(v.kind, 'unknown');
+ assert.equal(v.publishable, false);
+});
+
+// ── rung 2: „Документ" ────────────────────────────────────────────────────────
+test('rung 2 — a full-name match in a live ownership field publishes, with the role kept', () => {
+ const v = evidenceVerdict(base);
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+ assert.equal(v.registryRole, 'owner');
+ assert.equal(v.matchedFact, 'role:owner:CR_F_19_L');
+ assert.equal(v.entryNumber, '20110502101007');
+ assert.equal(v.entryDate, '2011-05-02');
+});
+
+test('rung 2 — a manager-only match publishes but records the weaker role', () => {
+ const mgr = deed([
+ fld('CR_F_7_L', container('ИВАН ПЕТРОВ ТЕСТОВ, Държава: БЪЛГАРИЯ')),
+ fld('CR_F_19_L', container('ДРУГО ЛИЦЕ ТУК')),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: mgr });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.registryRole, 'manager');
+ assert.equal(v.matchedFact, 'role:manager:CR_F_7_L');
+});
+
+test('rung 2 — a TWO-token declarant can never earn „Документ"', () => {
+ // 46 of 301 measured matches were two-token only, which is exactly the homonym risk. Falls to a
+ // lower rung rather than publishing on a name that half a register could satisfy.
+ const two = deed([fld('CR_F_19_L', container('ИВАН ТЕСТОВ, Държава: БЪЛГАРИЯ'))]);
+ const v = evidenceVerdict({ ...base, deed: two, declarantName: 'Иван Тестов' });
+ assert.notEqual(v.kind, 'document');
+ assert.equal(v.shortName, true, 'the refusal is counted, not silently dropped');
+});
+
+test('rung 2 — the match must fall inside ONE entity (the libel guard, end to end)', () => {
+ const two = deed([
+ fld('CR_F_19_L', joined('ПЕНКО НЕСТОРОВ НЕСТОРОВ', 'ИЛИЯН КОСТАДИНОВ ФИЛИПОВ')),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: two, declarantName: 'ПЕНКО КОСТАДИНОВ ФИЛИПОВ' });
+ assert.notEqual(v.kind, 'document');
+});
+
+test('rung 2 — an ERASED ownership entry cannot produce a document match', () => {
+ const gone = deed([fld('CR_F_23_L', ERASED, '2013-07-16T10:10:07')]);
+ const v = evidenceVerdict({ ...base, deed: gone });
+ assert.notEqual(v.kind, 'document');
+});
+
+test('rung 2 — a Latin homoglyph in the name is a NON-match, and is counted', () => {
+ // company-name-key.ts deliberately does not fold Cyrillic↔Latin; person names take the same posture.
+ const v = evidenceVerdict({ ...base, declarantName: 'ИBAH ПЕТРОВ ТЕСТОВ' }); // Latin B, A, H
+ assert.notEqual(v.kind, 'document');
+ assert.equal(v.latinInName, true);
+});
+
+// ── rung 3: „Потвърдено" ──────────────────────────────────────────────────────
+test('rung 3 — a declared seat matching the registered seat confirms the company', () => {
+ const other = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив, п.к. 4000'), '2015-01-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: other, declaredSeats: ['Пловдив'] });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.publishable, true);
+ assert.equal(v.matchedFact, 'seat:ПЛОВДИВ');
+});
+
+test('rung 3 — a declared ЕИК confirms the company on its own', () => {
+ const other = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК'))]);
+ const v = evidenceVerdict({ ...base, deed: other, declaredEik: true });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'eik');
+});
+
+test('rung 3 — an EMPTY declared seat never confirms', () => {
+ const noSeat = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК'))]);
+ const v = evidenceVerdict({ ...base, deed: noSeat, declaredSeats: ['', ' '] });
+ assert.notEqual(v.kind, 'confirmed');
+});
+
+test('rung 3 — a seat registered AFTER the declared period does not confirm', () => {
+ // R10, and W0 measured that seats move: a company that relocated INTO the declared settlement after
+ // the fact would otherwise produce a false „Потвърдено".
+ const moved = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив'), '2024-06-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: moved,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: 2021,
+ });
+ assert.notEqual(v.kind, 'confirmed');
+});
+
+test('rung 3 — an UNKNOWN first declared year cannot confirm on a seat', () => {
+ // R10 again, from the other side. `load.mjs` passes `firstDeclaredYear: null` whenever no history row
+ // carried a parseable year, and a null year means the temporal check has NOTHING to compare against —
+ // not that the seat covers the period. The same relocated company as the test above, with the year
+ // unknown instead of 2021, must reach the same held outcome: an unknown guard is a failed guard.
+ const moved = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив'), '2024-06-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: moved,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: null,
+ });
+ assert.notEqual(v.kind, 'confirmed');
+});
+
+test('rung 3 — an unknown year holds a seat match even when the seat has NO entry date', () => {
+ // The nastier half: with no entry date on the register side AND no year on the declaration side there
+ // are two unknowns and zero evidence about the period, yet both legs of the old disjunction read TRUE.
+ // Rung 4's refutation leg already refuses to run without a year (`firstDeclaredYear != null`); the seat
+ // leg must refuse on the same ground, or the weakest rung is the one with no temporal check at all.
+ const undated = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: undated,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: null,
+ });
+ assert.notEqual(v.kind, 'confirmed', 'two unknowns must not multiply into a public claim');
+});
+
+test('rung 3 — a KNOWN year with an undated seat still confirms (the guard is not a blanket)', () => {
+ // Positive control. Bounding the null case must not quietly kill the rung: a seat with no entry date
+ // is the ordinary shape for a company that never moved, and it still confirms under a known year.
+ const undated = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: undated,
+ declaredSeats: ['Пловдив'],
+ firstDeclaredYear: 2021,
+ });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'seat:ПЛОВДИВ');
+});
+
+test('rung 3 — the weakest rung ALSO requires global name uniqueness (ADR-0017 carried forward)', () => {
+ const other = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: other,
+ declaredSeats: ['Пловдив'],
+ nameGloballyUnique: false,
+ });
+ assert.notEqual(v.kind, 'confirmed', 'a nationally shared name cannot ride the weakest rung');
+});
+
+test('rung 3 — a declared ЕИК is NOT gated by name uniqueness (ADR-0028)', () => {
+ // The case ADR-0017 was written about — a фирма backing two ЕИК — is exactly where a declarant-supplied
+ // ЕИК is most valuable. Gating it on the name would discard the strongest identifier precisely when the
+ // name is useless.
+ const other = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК'))]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: other,
+ declaredEik: true,
+ nameGloballyUnique: false,
+ });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'eik');
+});
+
+test('rung 3 — the seat rung DOES rescue a merely generic name; it is uniqueness that gates it', () => {
+ // The seat leg exists to rescue generic names (a bare one- or two-word фирма). Requiring the name to
+ // be distinctive would empty the rung of its entire purpose; only NATIONAL non-uniqueness blocks it.
+ const generic = deed([
+ fld('CR_F_19_L', container('НЯКОЙ ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: generic,
+ declaredSeats: ['Пловдив'],
+ nameGloballyUnique: true,
+ });
+ assert.equal(v.kind, 'confirmed');
+ assert.equal(v.matchedFact, 'seat:ПЛОВДИВ');
+});
+
+test('rung 3 — name uniqueness does NOT gate the stronger „Документ" rung', () => {
+ const v = evidenceVerdict({ ...base, nameGloballyUnique: false });
+ assert.equal(
+ v.kind,
+ 'document',
+ 'the registry named the person in THIS company; the name key is moot',
+ );
+});
+
+test('rung 2 — every OWNERSHIP field code can carry the match, not just CR_F_19_L', () => {
+ // OWNERSHIP_FIELDS is ['CR_F_18_L','CR_F_19_L','CR_F_23_L'] — едноличен собственик, съдружници, and
+ // ФЛ-търговец. Every owner test used CR_F_19_L, so a typo or a dropped entry in the other two would
+ // have silently withheld an entire ownership shape: a sole owner (the commonest ЕООД form) publishing
+ // as „Неизвестна" is a recall hole with no symptom.
+ for (const code of ['CR_F_18_L', 'CR_F_19_L', 'CR_F_23_L']) {
+ const d = deed([fld(code, container('ИВАН ПЕТРОВ ТЕСТОВ'))]);
+ const v = evidenceVerdict({ ...base, deed: d });
+ assert.equal(v.kind, 'document', `${code} must carry an ownership match`);
+ assert.equal(v.registryRole, 'owner', `${code} is an OWNERSHIP field, not management`);
+ assert.equal(v.matchedFact, `role:owner:${code}`);
+ }
+ // POSITIVE CONTROL — a field that is NOT an ownership or manager field must not match at all, or the
+ // loop above would pass for a reason other than the one it claims.
+ const other = deed([fld('CR_F_99_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))]);
+ assert.notEqual(evidenceVerdict({ ...base, deed: other }).kind, 'document');
+});
+
+// ── rung 2's company gate: the winner-vs-non-winner homonym (ADR-0035) ────────
+//
+// The ladder proves a person with these three tokens is registered in the company we LOOKED UP. It cannot
+// prove that company is the one the official declared. `resolveEntity` maps a declared name to the sole
+// WINNER holding it, and `nameGloballyUnique` ranges over procurement bidders only — never the whole
+// register. So when an official owns a same-named company that never bid, we resolve to the winner, and a
+// three-token homonym in the winner's deed „proves" a link that is false in both halves.
+//
+// Two coincidences, and neither is rare in Bulgaria: a shared фирма and a shared three-part name. The gate
+// asks for a reason to believe the COMPANY is the declared one before rung 2 may assert.
+test('rung 2 — a GENERIC company name with no corroboration cannot publish on a name match alone', () => {
+ const v = evidenceVerdict({ ...base, companyNameDistinctive: false });
+ assert.equal(
+ v.kind,
+ 'document_uncorroborated',
+ 'the deed names SOMEONE with this name in THIS company — not that this is the declared company',
+ );
+ assert.equal(v.publishable, false);
+ // It must not fall through to `unknown`: the residual is the input to F8's decision on whether this gate
+ // tightens, and a rung-2 match withheld for want of company identity is a different fact from no match.
+ assert.equal(v.registryRole, null);
+ assert.equal(v.matchedFact, null);
+});
+
+test('rung 2 — a declared ЕИК corroborates the company, so the name match publishes', () => {
+ // POSITIVE CONTROL. The ЕИК IS the identity (ЗТРРЮЛНЦ, ADR-0028) — it resolves the company behind any
+ // shared фирма, which is exactly the collision the gate is about.
+ const v = evidenceVerdict({ ...base, companyNameDistinctive: false, declaredEik: true });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+ assert.equal(v.registryRole, 'owner');
+});
+
+test('rung 2 — a declared seat matching the registered seat corroborates the company', () => {
+ // POSITIVE CONTROL. The declarant put this company in гр. Пловдив and the register agrees; a national
+ // twin in another town is excluded by the same fact rung 3 publishes on.
+ const v = evidenceVerdict({
+ ...base,
+ companyNameDistinctive: false,
+ declaredSeats: ['гр. Пловдив'],
+ });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+});
+
+test('rung 2 — a DISTINCTIVE company name publishes uncorroborated (the gate is not a blanket)', () => {
+ // POSITIVE CONTROL, and the one that distinguishes this fix from disabling rung 2. A predicate that
+ // always withheld would satisfy the bar above; this is what it must NOT do.
+ const v = evidenceVerdict({ ...base, companyNameDistinctive: true });
+ assert.equal(v.kind, 'document');
+ assert.equal(v.publishable, true);
+});
+
+test('rung 2 — a seat that does NOT match cannot corroborate a generic name', () => {
+ // The corroborator has to actually corroborate. A declared seat in another town is evidence AGAINST the
+ // company being the declared one, so it certainly cannot rescue the rung.
+ const v = evidenceVerdict({
+ ...base,
+ companyNameDistinctive: false,
+ declaredSeats: ['гр. Бургас'], // the deed says Пловдив
+ });
+ assert.equal(v.kind, 'document_uncorroborated');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 2 — the seat corroborator carries the SAME temporal guard as rung 3 (R10)', () => {
+ // A seat registered after the declared period cannot corroborate anything: the company may have moved
+ // INTO that town afterwards. Rung 3 already refuses it; rungs 2 and 3 share one implementation so they
+ // cannot drift into disagreeing about what a seat match means.
+ const moved = deed([
+ fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ')),
+ fld(
+ 'CR_F_5_L',
+ container('Държава: БЪЛГАРИЯ Населено място: гр. Пловдив, п.к. 4000'),
+ '2023-07-01T00:00:00',
+ ),
+ ]);
+ const v = evidenceVerdict({
+ ...base,
+ deed: moved,
+ companyNameDistinctive: false,
+ declaredSeats: ['гр. Пловдив'],
+ firstDeclaredYear: 2021,
+ });
+ assert.equal(v.kind, 'document_uncorroborated');
+});
+
+test('rung 2 — the company gate never rescues a link rung 1 has barred', () => {
+ // Ordering: a joint-stock bar outranks everything, corroborated or not. The gate adds a way to WITHHOLD,
+ // never a way to publish something a stronger rung refused.
+ const ad = deed([fld('CR_F_19_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))], {
+ legalForm: 5,
+ fullName: '"ГАМА ИНВЕСТ" АД',
+ });
+ const v = evidenceVerdict({ ...base, deed: ad, companyNameDistinctive: true, declaredEik: true });
+ assert.equal(v.kind, 'bar_joint_stock');
+});
+
+// ── rung 4: „Оборена" ─────────────────────────────────────────────────────────
+test('rung 4 — absent from a deed whose ownership predates the declaration refutes the link', () => {
+ const older = deed([
+ fld('CR_F_19_L', container('СЪВСЕМ ДРУГ СОБСТВЕНИК'), '2015-03-01T00:00:00'),
+ ]);
+ const v = evidenceVerdict({ ...base, deed: older, firstDeclaredYear: 2021 });
+ assert.equal(v.kind, 'refuted');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 4 — the comparison is date-to-DATE, not date-to-year', () => {
+ // R17: „strictly before the first declared year" means before YYYY-01-01. An entry inside the first
+ // declared year does NOT cover the period and must not refute.
+ const inYear = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2021-06-15T00:00:00')]);
+ assert.notEqual(
+ evidenceVerdict({ ...base, deed: inYear, firstDeclaredYear: 2021 }).kind,
+ 'refuted',
+ );
+ const justBefore = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2020-12-31T00:00:00')]);
+ assert.equal(
+ evidenceVerdict({ ...base, deed: justBefore, firstDeclaredYear: 2021 }).kind,
+ 'refuted',
+ );
+});
+
+test('rung 4 — NEVER applies to a family stake', () => {
+ // The owner there is the relative, whose name we neither store nor check (ADR-0010 item 4,
+ // ADR-0032 decision 2), so „the official is not in the deed" says nothing at all.
+ const older = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2015-03-01T00:00:00')]);
+ const v = evidenceVerdict({ ...base, deed: older, scope: 'family', firstDeclaredYear: 2021 });
+ assert.notEqual(v.kind, 'refuted');
+ assert.equal(v.kind, 'unknown');
+});
+
+test('rung 4 — suppressed inside the 2011–2012 re-registration window', () => {
+ // R13: court-registered companies had every entry date flattened into the re-registration window,
+ // so „strictly before" certifies nothing there.
+ const flattened = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2011-11-04T00:00:00')]);
+ const v = evidenceVerdict({ ...base, deed: flattened, firstDeclaredYear: 2021 });
+ assert.notEqual(v.kind, 'refuted');
+ assert.equal(v.kind, 'unknown');
+});
+
+// ── rungs 5 and 6 ─────────────────────────────────────────────────────────────
+test('rung 5 — everything else is „Неизвестна" and stays hidden', () => {
+ const recent = deed([fld('CR_F_19_L', container('ДРУГ СОБСТВЕНИК'), '2023-01-01T00:00:00')]);
+ const v = evidenceVerdict({ ...base, deed: recent, firstDeclaredYear: 2021 });
+ assert.equal(v.kind, 'unknown');
+ assert.equal(v.publishable, false);
+});
+
+test('rung 6 — outside the register is its own outcome, and is not publishable', () => {
+ const v = evidenceVerdict({ ...base, deed: null, outsideTr: true });
+ assert.equal(v.kind, 'outside_tr');
+ assert.equal(v.publishable, false);
+});
+
+test('a missing deed that is NOT marked outside-ТР is an error, not a silent hold', () => {
+ // Fail closed: a cache gap must be visible, never quietly downgraded to „unknown".
+ assert.throws(() => evidenceVerdict({ ...base, deed: null, outsideTr: false }), /deed/i);
+});
+
+// ── the seal ──────────────────────────────────────────────────────────────────
+test('MATCHED_FACT_RE bounds a settlement to two tokens — a NAME cannot wear the seat: prefix', () => {
+ // The rail tested DIRECTLY, not just through whatever verdicts the ladder happens to produce. Both
+ // seal tests previously restated this regex locally and got it WRONG in the permissive direction —
+ // `seat:` followed by unlimited uppercase tokens — so a three-part Bulgarian name (ЗГР чл. 9) wearing
+ // an allowed prefix passed them. That value is exactly what a mis-split of the seat field produces,
+ // and it is the one shape this rail exists to keep off a served column.
+ for (const ok of [
+ 'seat:СОФИЯ',
+ 'seat:ВЕЛИКО ТЪРНОВО', // a real two-token settlement must still pass
+ 'seat:ГЕНЕРАЛ ТОШЕВО',
+ 'seat:ЦАР-КАЛОЯН', // hyphenated is one token
+ 'role:owner:CR_F_19_L',
+ 'role:manager:CR_F_7_L',
+ 'role:owner:CR_F_23_L',
+ 'eik',
+ ])
+ assert.equal(MATCHED_FACT_RE.test(ok), true, `wrongly rejected: ${ok}`);
+
+ for (const bad of [
+ 'seat:ИВАН ПЕТРОВ ГЕОРГИЕВ', // THE case: three tokens is a name, not a settlement
+ 'seat:ИВАН ПЕТРОВ ГЕОРГИЕВ ДРУГ',
+ 'ИВАН ПЕТРОВ ГЕОРГИЕВ', // a bare name with no prefix at all
+ 'role:owner:ИВАН ПЕТРОВ', // a name where a field code belongs
+ 'role:cashier:CR_F_19_L', // a role outside the vocabulary
+ 'seat:', // an empty settlement asserts nothing
+ 'eik:201122335', // the ЕИК itself is never stored, only the fact that one matched
+ ])
+ assert.equal(MATCHED_FACT_RE.test(bad), false, `wrongly accepted: ${bad}`);
+
+ // null is legal — a rung may match no fact — and that is isSealedFact's job, not the regex's.
+ assert.equal(isSealedFact(null), true);
+ assert.equal(isSealedFact('seat:ИВАН ПЕТРОВ ГЕОРГИЕВ'), false);
+});
+
+test('matched_fact stays inside the closed vocabulary — it can never carry a name', () => {
+ // The PRODUCTION predicate, imported — never a local copy of it. A re-stated regex here was looser
+ // than `MATCHED_FACT_RE` (it allowed `seat:` + unlimited tokens), so this loop certified values the
+ // real rail rejects and could not fail on the regression it exists to catch (cefothe, #309).
+ for (const v of [
+ evidenceVerdict(base),
+ evidenceVerdict({ ...base, deed: deed([fld('CR_F_7_L', container('ИВАН ПЕТРОВ ТЕСТОВ'))]) }),
+ evidenceVerdict({ ...base, declaredEik: true }),
+ evidenceVerdict({
+ ...base,
+ deed: deed([
+ fld('CR_F_19_L', container('ДРУГ ЧОВЕК')),
+ fld('CR_F_5_L', container('Населено място: гр. Пловдив')),
+ ]),
+ declaredSeats: ['Пловдив'],
+ }),
+ ]) {
+ if (v.matchedFact == null) continue;
+ assert.ok(isSealedFact(v.matchedFact), `matched_fact escaped the vocabulary: ${v.matchedFact}`);
+ assert.ok(!/ИВАН|ПЕТРОВ|ТЕСТОВ/.test(v.matchedFact), 'a NAME reached matched_fact');
+ }
+});
+
+test('every verdict carries the rules version that produced it', () => {
+ assert.equal(evidenceVerdict(base).rulesVersion, RULES_VERSION);
+});
+
+// ── §7 reconciliation ─────────────────────────────────────────────────────────
+test('reconcileTermination — still a registered owner ⇒ NOT terminated', () => {
+ const r = reconcileTermination({
+ deed: OWNER_DEED,
+ declarantName: 'Иван Петров Тестов',
+ scope: 'self',
+ });
+ assert.equal(r.terminated, false);
+ assert.equal(r.label, 'owner_today');
+});
+
+test('reconcileTermination — manager only ⇒ terminated as a stake, but the tie continues', () => {
+ const mgr = deed([
+ fld('CR_F_7_L', container('ИВАН ПЕТРОВ ТЕСТОВ')),
+ fld('CR_F_19_L', container('ДРУГ')),
+ ]);
+ const r = reconcileTermination({ deed: mgr, declarantName: 'Иван Петров Тестов', scope: 'self' });
+ assert.equal(r.terminated, true);
+ assert.equal(r.label, 'manager_today');
+});
+
+test('reconcileTermination — absent from the live deed ⇒ the declared termination stands', () => {
+ const none = deed([fld('CR_F_19_L', container('НЯКОЙ ДРУГ'))]);
+ const r = reconcileTermination({
+ deed: none,
+ declarantName: 'Иван Петров Тестов',
+ scope: 'self',
+ });
+ assert.equal(r.terminated, true);
+ assert.equal(r.label, null);
+});
+
+test('reconcileTermination — a FAMILY stake is never reconciled, by an early branch', () => {
+ // Structural, not a caller convention: the relative's name is not stored, so there is nothing to
+ // look for, and looking would be a de-anonymisation attempt.
+ const r = reconcileTermination({
+ deed: OWNER_DEED,
+ declarantName: 'Иван Петров Тестов',
+ scope: 'family',
+ });
+ assert.equal(r.terminated, true);
+ assert.equal(r.label, null);
+});
diff --git a/scripts/tr/fetch-deeds.mjs b/scripts/tr/fetch-deeds.mjs
new file mode 100644
index 00000000..cdea4f73
--- /dev/null
+++ b/scripts/tr/fetch-deeds.mjs
@@ -0,0 +1,296 @@
+// The deed crawler (issue #279 §3, ADR-0033). One request per candidate ЕИК, sequential and paced.
+//
+// This is the only component in the project that touches a public register at volume, so what it
+// REFUSES to do is the substance:
+//
+// • It never goes faster than 1 request / 3 s, and the flag that sets the pace cannot be used to go
+// faster — only slower. Spec §3.3 permits a bounded per-ЕИК lookup and forbids bulk scraping; the
+// limiter is the operator's only way to state a preference, and tuning around it empirically is
+// what that rule exists to prevent.
+// • It never retries a 429. The block is sustained (see client.mjs), so a 429 ends the run with its
+// own exit code and records NOTHING about the ЕИК that hit it — that ЕИК is unknown, not absent.
+// • It never follows a link out of a deed. The candidate set is closed: whatever the caller passes
+// in, nothing more. This is what keeps a bounded lookup from drifting into a crawl.
+// • It only writes „outside the register" on a DOCUMENTED negative — measured to be an HTTP 200
+// with an empty body, not the 404 the issue predicts. A 5xx or a timeout is transient, and caching
+// it as permanent would turn an outage into data that §8 never revisits.
+//
+// Resumable by construction: the cache is consulted first, so an interrupted run picks up exactly
+// where it stopped and a complete cache costs zero requests.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { assertTrScratchIgnored, TR_DB, TR_RAW, safeEik, deedPath } from './paths.mjs';
+import { eikChecksumValid } from './eik.mjs';
+import { deedUrl, politeTrGet, RateLimitError, httpsGet } from './client.mjs';
+import {
+ openCache,
+ upsertDeed,
+ markOutsideTr,
+ pendingEiks,
+ purgeExpired,
+ RETENTION_DAYS,
+} from './cache.mjs';
+import {
+ assertUicEcho,
+ registryLegalForm,
+ registrySeat,
+ latestOwnershipEntryDate,
+} from './deed.mjs';
+
+/** The documented polite pace: 1 request / 3 s (#279 §3). A floor, never a target to tune down. */
+export const MIN_INTERVAL_MS = 3000;
+
+/**
+ * Consecutive unresolved ЕИК before the run gives up rather than keep hammering.
+ *
+ * Deliberately small, because the unit is ЕИК and not requests: each unresolved candidate costs up to
+ * 5 attempts (#279 §3's documented retry budget), so the breaker's real cost is `BREAKER_TRIP × 5`
+ * requests against an endpoint that is already failing. At 10 that would be ~50 — the exact volume at
+ * which the register was observed to start returning a sustained 429. At 5 the worst case is ~25,
+ * which the same observation saw pass without a block.
+ */
+export const BREAKER_TRIP = 5;
+/** Attempts per candidate, per #279 §3. Exported so the breaker's request budget is derivable. */
+export const TRIES_PER_EIK = 5;
+
+export function parseTrOptions(argv) {
+ const get = (name, def) => {
+ const i = argv.indexOf(`--${name}`);
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
+ };
+ const posInt = (raw, name) => {
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n < 1)
+ throw new Error(`--${name} must be a positive integer, got ${JSON.stringify(raw)}`);
+ return n;
+ };
+
+ const eiksFile = get('eiks-file', '');
+ if (!eiksFile) throw new Error('--eiks-file is required (the closed candidate set)');
+
+ const limitRaw = get('limit', '');
+ const intervalRaw = get('min-interval-ms', '');
+ const minIntervalMs = intervalRaw ? posInt(intervalRaw, 'min-interval-ms') : MIN_INTERVAL_MS;
+ // Slower is always allowed; faster is not a knob. Making this un-passable is the point.
+ if (minIntervalMs < MIN_INTERVAL_MS) {
+ throw new Error(
+ `--min-interval-ms may not go below the documented pace of ${MIN_INTERVAL_MS}ms — ` +
+ `the register's rate limit is a stated preference, not an obstacle to tune around`,
+ );
+ }
+ const maxAgeRaw = get('max-age-days', '');
+ // --max-age-days is FRESHNESS (re-request past it); --retention-days is RETENTION (delete past it).
+ // They are separate flags because they are separate obligations: refreshing rewrites the personal
+ // data, only purging removes it. Retention defaults to the ADR's 35 days rather than to „off", so
+ // the rail holds for an operator who passes neither.
+ const retentionRaw = get('retention-days', '');
+ return {
+ eiksFile,
+ limit: limitRaw ? posInt(limitRaw, 'limit') : Infinity,
+ minIntervalMs,
+ maxAgeDays: maxAgeRaw ? posInt(maxAgeRaw, 'max-age-days') : null,
+ retentionDays: retentionRaw ? posInt(retentionRaw, 'retention-days') : RETENTION_DAYS,
+ };
+}
+
+/** Read the closed candidate set: one ЕИК per line, blanks and `#` comments ignored. */
+export function readEiksFile(file) {
+ return fs
+ .readFileSync(file, 'utf8')
+ .split('\n')
+ .map((l) => l.trim())
+ .filter((l) => l !== '' && !l.startsWith('#'));
+}
+
+function atomicWrite(file, buf) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.tmp-${process.pid}`;
+ fs.writeFileSync(tmp, buf);
+ fs.renameSync(tmp, file);
+}
+
+/**
+ * Crawl the candidate ЕИК. Returns the intended process exit code, so the decision is testable
+ * without a global side effect:
+ * 0 — every candidate resolved (or the run was deliberately bounded by --limit)
+ * 1 — at least one candidate is unresolved (transient failure, refused deed, breaker tripped)
+ * 2 — the register rate-limited us; the run stopped and nothing was marked
+ *
+ * Every I/O edge is injectable so the whole policy is exercised offline.
+ */
+export async function run({
+ httpGet = httpsGet,
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
+ now = () => new Date(),
+ guard = assertTrScratchIgnored,
+ dbFile = TR_DB,
+ rawDir = TR_RAW,
+ argv = process.argv,
+} = {}) {
+ guard();
+ const { eiksFile, limit, minIntervalMs, maxAgeDays, retentionDays } = parseTrOptions(argv);
+
+ const requested = readEiksFile(eiksFile);
+ // A shape- or checksum-invalid code is dropped BEFORE any request: it cannot name a real company,
+ // so asking about it would spend the register's budget to learn nothing.
+ const candidates = [];
+ let invalid = 0;
+ for (const raw of requested) {
+ try {
+ const eik = safeEik(raw);
+ if (!eikChecksumValid(eik)) throw new Error('checksum');
+ candidates.push(eik);
+ } catch {
+ invalid++;
+ }
+ }
+
+ const db = openCache(dbFile);
+ try {
+ const pending = pendingEiks(db, candidates, { maxAgeDays, now: now() });
+ const todo = Number.isFinite(limit) ? pending.slice(0, limit) : pending;
+ console.log(
+ `candidates ${candidates.length} · invalid ${invalid} · cached ${candidates.length - pending.length} · to fetch ${todo.length}`,
+ );
+
+ let unresolved = 0;
+ let consecutive = 0;
+ let first = true;
+
+ for (const eik of todo) {
+ if (!first) await sleep(minIntervalMs); // pace BETWEEN requests, not before the first
+ first = false;
+
+ let res;
+ try {
+ res = await politeTrGet(deedUrl(eik), { httpGet, sleep, tries: TRIES_PER_EIK });
+ } catch (err) {
+ if (err instanceof RateLimitError) {
+ // Stop the whole run. Recording anything here would attribute the register's throttle to
+ // this ЕИК, which is a fact about us, not about the company.
+ console.error(`${err.message}\nSTOPPING — re-run later; progress so far is cached.`);
+ return 2;
+ }
+ console.error(
+ ` ${eik}: ${err instanceof Error ? err.message : err} (transient, not cached)`,
+ );
+ unresolved++;
+ consecutive++;
+ if (consecutive >= BREAKER_TRIP) {
+ console.error(`breaker: ${consecutive} consecutive failures — aborting the run`);
+ return 1;
+ }
+ continue;
+ }
+
+ // ── the documented negatives ────────────────────────────────────────────
+ // MEASURED 2026-08-05: an ЕИК that is not a търговец answers **HTTP 200 with a ZERO-BYTE body**,
+ // not a 404 and not the HTML #279 §3 predicts. Verified on Община София (000696327): empty on
+ // two consecutive requests, while a real company returned its full 34,398-byte deed in the same
+ // window — so it is the register's answer, not an outage.
+ //
+ // The distinction that keeps R6 honest is the STATUS, not the empty body: an empty body under
+ // 200 is the register saying „no deed"; an empty body under 5xx is a failure and stays
+ // transient. Getting this backwards either caches a false negative forever or leaves ~4 ЕИК
+ // permanently unresolved so the run can never exit 0.
+ if (res.status === 200 && res.body.length === 0) {
+ markOutsideTr(db, eik, 'HTTP 200, empty body — no deed in the Търговски регистър', now());
+ consecutive = 0;
+ continue;
+ }
+ if (res.status === 404) {
+ markOutsideTr(db, eik, 'HTTP 404 — not in the Търговски регистър (BULSTAT/ДЗЗД?)', now());
+ consecutive = 0;
+ continue;
+ }
+ if (res.status !== 200) {
+ console.error(` ${eik}: HTTP ${res.status} after retries (transient, not cached)`);
+ unresolved++;
+ consecutive++;
+ if (consecutive >= BREAKER_TRIP) {
+ console.error(`breaker: ${consecutive} consecutive failures — aborting the run`);
+ return 1;
+ }
+ continue;
+ }
+
+ // ONE refuse-and-continue block around EVERYTHING derived from the response — the JSON, the UIC
+ // echo, and the HTML parsing alike. The parsing used to sit outside it, which made the block's
+ // own promise false: a throw from deed.mjs escaped the loop, escaped run(), and killed the
+ // process, so one malformed deed ended a crawl that had already spent its paced request budget.
+ // The decode guard in deed.mjs removes the one throw we know of; this is the rail that holds when
+ // the next one appears, and the cost of being wrong here is measured in hours of pacing.
+ try {
+ const deed = JSON.parse(res.body.toString('utf8'));
+ // The deed we got back must be the deed we asked for, or every claim derived from it names
+ // the wrong company (R8).
+ assertUicEcho(deed, eik);
+
+ // The raw response is the ONLY place names live; it stays under git-ignored scratch. Written
+ // only after the echo check, so a deed for the wrong company never lands on disk.
+ atomicWrite(deedPath(eik, rawDir), res.body);
+ const seat = registrySeat(deed);
+ const form = registryLegalForm(deed);
+ upsertDeed(db, {
+ eik,
+ status: 'fetched',
+ httpStatus: 200,
+ fetchedAt: now().toISOString(),
+ rawPath: path.relative(rawDir, deedPath(eik, rawDir)),
+ bodySha256: crypto.createHash('sha256').update(res.body).digest('hex'),
+ legalFormCode: form.code,
+ legalFormVerdict: form.verdict,
+ seatNormalized: seat.settlement || null,
+ seatEntryDate: seat.entryDate,
+ latestOwnEntryDate: latestOwnershipEntryDate(deed),
+ });
+ } catch (err) {
+ console.error(` ${eik}: REFUSED — ${err instanceof Error ? err.message : err}`);
+ unresolved++;
+ consecutive++;
+ continue;
+ }
+ consecutive = 0;
+ }
+
+ if (unresolved > 0) {
+ console.error(`${unresolved} candidate(s) unresolved — the cache is incomplete`);
+ return 1;
+ }
+ return 0;
+ } finally {
+ // The purge step ADR-0033 decision 5 puts „in the same job" — in `finally`, and that placement is
+ // the point. Retention is an obligation about other people's data, not a reward for a clean run,
+ // so it must also happen on the paths that leave early: a 429 (exit 2), a tripped breaker, an
+ // unresolved candidate. Under normal operation it removes nothing, because the monthly refresh
+ // rewrites each row well inside the window; anything it does delete is residue — a company that
+ // left the candidate set, or a refresh that never landed.
+ try {
+ const purged = purgeExpired(db, rawDir, { retentionDays, now: now() });
+ if (purged.rows || purged.files || purged.orphans) {
+ console.log(
+ `purged ${purged.rows} row(s), ${purged.files} raw deed(s), ${purged.orphans} orphan(s) past ${retentionDays}d retention`,
+ );
+ }
+ } catch (e) {
+ // A failed purge must be loud but must not mask the run's own outcome — especially not a 429,
+ // whose exit code is what tells the operator to back off.
+ console.error(`purge failed: ${e.message}`);
+ }
+ db.close();
+ }
+}
+
+// CLI entry. Kept off the import path so the module stays testable.
+if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
+ run()
+ .then((code) => {
+ process.exitCode = code;
+ })
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/tr/fetch-deeds.test.mjs b/scripts/tr/fetch-deeds.test.mjs
new file mode 100644
index 00000000..3a4b1dc0
--- /dev/null
+++ b/scripts/tr/fetch-deeds.test.mjs
@@ -0,0 +1,427 @@
+// node:test — the deed crawler, driven entirely through injected I/O. No network, no real scratch.
+//
+// This is the only component that touches a public register at volume, so what it must NOT do is the
+// substance: never exceed the pace, never retry a 429, never turn a transient wall into permanent
+// data, and never re-request what it already holds. Spec §3.3 permits a bounded per-ЕИК lookup and
+// forbids bulk scraping; the difference between the two is enforced here.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+import {
+ parseTrOptions,
+ run,
+ MIN_INTERVAL_MS,
+ BREAKER_TRIP,
+ TRIES_PER_EIK,
+} from './fetch-deeds.mjs';
+
+const DEED = (uic) => ({
+ uic,
+ fullName: '"АЛФА СТРОЙ" ООД',
+ legalForm: 4,
+ sections: [
+ {
+ subDeeds: [
+ {
+ groups: [
+ {
+ fields: [
+ {
+ nameCode: 'CR_F_19_L',
+ htmlData: `
`,
+ fieldEntryNumber: '20110502101008',
+ fieldEntryDate: '2011-05-02T00:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
+
+const ok = (uic) => ({
+ status: 200,
+ headers: {},
+ body: Buffer.from(JSON.stringify(DEED(uic)), 'utf8'),
+});
+const status = (s) => ({ status: s, headers: {}, body: Buffer.from('') });
+
+// Two valid ЕИК (real checksums) plus one that is shape-valid but checksum-invalid.
+const A = '201122335';
+const B = '203445566';
+const C = '204556676';
+const BAD_CHECKSUM = '201122336';
+
+function ctx() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-crawl-'));
+ return {
+ dir,
+ dbFile: path.join(dir, 'tr-cache.sqlite'),
+ rawDir: path.join(dir, 'deeds'),
+ cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
+ };
+}
+const eiksFile = (dir, eiks) => {
+ const f = path.join(dir, 'eiks.txt');
+ fs.writeFileSync(f, eiks.join('\n') + '\n');
+ return f;
+};
+
+/** Drive run() with a recording transport. `routes` maps ЕИК → response (or a function of attempt). */
+function harness(c, eiks, routes, extraArgv = []) {
+ const calls = [];
+ const waits = [];
+ const httpGet = async (url) => {
+ const eik = url.split('/').pop();
+ calls.push(eik);
+ const r = routes[eik];
+ return typeof r === 'function' ? r(calls.filter((e) => e === eik).length) : (r ?? status(404));
+ };
+ return {
+ calls,
+ waits,
+ promise: run({
+ httpGet,
+ sleep: async (ms) => void waits.push(ms),
+ now: () => new Date('2026-08-05T12:00:00Z'),
+ guard: () => {},
+ dbFile: c.dbFile,
+ rawDir: c.rawDir,
+ argv: ['node', 'fetch-deeds.mjs', '--eiks-file', eiksFile(c.dir, eiks), ...extraArgv],
+ }),
+ };
+}
+
+const openCacheRO = (dbFile) => new DatabaseSync(dbFile);
+const rows = (dbFile) => {
+ const db = openCacheRO(dbFile);
+ const r = db.prepare('SELECT eik, status, outside_reason FROM deeds ORDER BY eik').all();
+ db.close();
+ return r;
+};
+
+// ── options ───────────────────────────────────────────────────────────────────
+test('parseTrOptions: defaults are the documented polite pace', () => {
+ const o = parseTrOptions(['node', 'x', '--eiks-file', '/tmp/e.txt']);
+ assert.equal(o.eiksFile, '/tmp/e.txt');
+ assert.equal(o.limit, Infinity);
+ assert.equal(o.minIntervalMs, MIN_INTERVAL_MS);
+ assert.ok(MIN_INTERVAL_MS >= 3000, 'the documented pace is 1 request / 3 s');
+});
+
+test('parseTrOptions: rejects a pace FASTER than the documented one', () => {
+ // Tuning around a limiter empirically is precisely what spec §3.3 forbids; make it un-passable.
+ assert.throws(
+ () => parseTrOptions(['node', 'x', '--eiks-file', '/e', '--min-interval-ms', '100']),
+ /min-interval/i,
+ );
+});
+
+test('parseTrOptions: --eiks-file is required, and numeric flags are validated', () => {
+ assert.throws(() => parseTrOptions(['node', 'x']), /eiks-file/i);
+ for (const bad of ['0', '-1', 'abc', '1.5'])
+ assert.throws(
+ () => parseTrOptions(['node', 'x', '--eiks-file', '/e', '--limit', bad]),
+ /limit/i,
+ bad,
+ );
+});
+
+// ── pacing ────────────────────────────────────────────────────────────────────
+test('requests are sequential and spaced by at least the documented interval', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [A, B, C], { [A]: ok(A), [B]: ok(B), [C]: ok(C) });
+ assert.equal(await h.promise, 0);
+ assert.deepEqual(h.calls, [A, B, C], 'sequential, in order — never concurrent');
+ const paces = h.waits.filter((w) => w >= MIN_INTERVAL_MS);
+ assert.ok(paces.length >= 2, `expected a pace wait between requests, got ${h.waits.join(',')}`);
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── 429 ───────────────────────────────────────────────────────────────────────
+test('a 429 ends the run with exit 2 and marks NOTHING', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [A, B, C], { [A]: ok(A), [B]: status(429), [C]: ok(C) });
+ assert.equal(await h.promise, 2, 'a rate-limit block is its own exit code');
+ assert.deepEqual(h.calls, [A, B], 'stops AT the 429 — C is never requested');
+ // B must not be recorded at all: it is unknown, not absent, and certainly not outside the register.
+ assert.deepEqual(
+ rows(c.dbFile).map((r) => r.eik),
+ [A],
+ );
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('after a 429 the run resumes exactly where it stopped', async () => {
+ const c = ctx();
+ try {
+ const first = harness(c, [A, B, C], { [A]: ok(A), [B]: status(429), [C]: ok(C) });
+ assert.equal(await first.promise, 2);
+ const second = harness(c, [A, B, C], { [A]: ok(A), [B]: ok(B), [C]: ok(C) });
+ assert.equal(await second.promise, 0);
+ assert.deepEqual(second.calls, [B, C], 'A is already cached — not re-requested');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── resumability ──────────────────────────────────────────────────────────────
+test('a re-run over a complete cache makes ZERO requests', async () => {
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A, B], { [A]: ok(A), [B]: ok(B) }).promise, 0);
+ const again = harness(c, [A, B], { [A]: ok(A), [B]: ok(B) });
+ assert.equal(await again.promise, 0);
+ assert.deepEqual(again.calls, [], 'nothing to fetch');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── permanence ────────────────────────────────────────────────────────────────
+test('an empty 200 is the register saying „no deed" and is cached as outside-ТР', async () => {
+ // MEASURED against the live API: an ЕИК that is not a търговец (Община София, 000696327) answers
+ // HTTP 200 with a ZERO-BYTE body — not the 404 or HTML #279 §3 predicts. Reproduced twice, with a
+ // real company returning its full deed in the same window, so it is an answer and not an outage.
+ const c = ctx();
+ try {
+ const empty = { status: 200, headers: {}, body: Buffer.alloc(0) };
+ assert.equal(await harness(c, [A], { [A]: empty }).promise, 0);
+ const [row] = rows(c.dbFile);
+ assert.equal(row.status, 'outside_tr');
+ assert.match(row.outside_reason, /empty body/i);
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('an empty body under a 5xx stays TRANSIENT — the status decides, not the emptiness', async () => {
+ // The pair that keeps R6 honest. Both responses have a zero-byte body; only the 200 is an answer.
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A], { [A]: status(503) }).promise, 1);
+ assert.deepEqual(rows(c.dbFile), [], 'a 5xx must never become permanent „outside ТР"');
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a 404 is a DOCUMENTED negative and is cached as outside-ТР', async () => {
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A], { [A]: status(404) }).promise, 0);
+ const [row] = rows(c.dbFile);
+ assert.equal(row.status, 'outside_tr');
+ assert.match(row.outside_reason, /404/);
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a persistent 5xx is TRANSIENT and is never cached as outside-ТР', async () => {
+ // R6: „outside the register" is permanent by intent. Writing it after a transient wall turns a
+ // temporary outage into permanent data that §8 never re-examines.
+ const c = ctx();
+ try {
+ const code = await harness(c, [A], { [A]: status(503) }).promise;
+ assert.equal(code, 1, 'an unresolved ЕИК makes the run incomplete');
+ assert.deepEqual(rows(c.dbFile), [], 'nothing may be recorded from a 5xx');
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a deed whose UIC does not echo the request is REFUSED, not cached', async () => {
+ // R8, at the crawl boundary: if anything rewrote the identifier we would be caching one company's
+ // deed under another company's ЕИК, and every downstream claim about it would name the wrong firm.
+ const c = ctx();
+ try {
+ const code = await harness(c, [A], { [A]: ok('999999999') }).promise;
+ assert.equal(code, 1);
+ assert.deepEqual(rows(c.dbFile), []);
+ assert.deepEqual(fs.existsSync(c.rawDir) ? fs.readdirSync(c.rawDir) : [], []);
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('a checksum-invalid ЕИК is skipped without ever being requested', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [BAD_CHECKSUM, A], { [A]: ok(A) });
+ assert.equal(await h.promise, 0);
+ assert.deepEqual(h.calls, [A], 'the invalid code costs the register nothing');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── output ────────────────────────────────────────────────────────────────────
+test('the raw deed is written under the raw dir and the index records only non-PII', async () => {
+ const c = ctx();
+ try {
+ assert.equal(await harness(c, [A], { [A]: ok(A) }).promise, 0);
+ assert.deepEqual(fs.readdirSync(c.rawDir), [`${A}.json`]);
+ const db = openCacheRO(c.dbFile);
+ const row = db.prepare('SELECT * FROM deeds WHERE eik = ?').get(A);
+ db.close();
+ assert.equal(row.status, 'fetched');
+ assert.equal(row.legal_form_verdict, 'closely_held');
+ assert.equal(row.seat_normalized, 'ПЛОВДИВ');
+ assert.equal(row.body_sha256.length, 64);
+ // The raw file holds the names; the index must not.
+ assert.ok(!JSON.stringify(row).includes('ТЕСТОВ'));
+ assert.ok(fs.readFileSync(path.join(c.rawDir, `${A}.json`), 'utf8').includes('ТЕСТОВ'));
+ } finally {
+ c.cleanup();
+ }
+});
+
+test('--limit bounds a run without marking the remainder as anything', async () => {
+ const c = ctx();
+ try {
+ const h = harness(c, [A, B, C], { [A]: ok(A), [B]: ok(B), [C]: ok(C) }, ['--limit', '2']);
+ assert.equal(await h.promise, 0, 'a deliberately bounded run is not an incomplete one');
+ assert.deepEqual(h.calls, [A, B]);
+ assert.deepEqual(
+ rows(c.dbFile).map((r) => r.eik),
+ [A, B],
+ );
+ } finally {
+ c.cleanup();
+ }
+});
+
+/**
+ * N genuinely checksum-valid 9-digit ЕИК. Generated, not hand-written: an earlier version of the
+ * breaker test below used made-up codes, of which only 1 in 10 was valid — so the crawler dropped
+ * them all before requesting anything and the assertion passed on zero calls. A test that exercises
+ * nothing is worse than no test (ADR-0027).
+ */
+function validEiks(n) {
+ const control = (p8) => {
+ const d = [...p8].map(Number);
+ let s = d.reduce((a, x, i) => a + x * (i + 1), 0) % 11;
+ if (s === 10) {
+ s = d.reduce((a, x, i) => a + x * (i + 3), 0) % 11;
+ if (s === 10) s = 0;
+ }
+ return s;
+ };
+ const out = [];
+ for (let i = 0; out.length < n; i++) {
+ const p8 = String(20000000 + i);
+ out.push(p8 + control(p8));
+ }
+ return out;
+}
+
+test('the ЕИК generator used by the breaker test really produces valid codes', () => {
+ const eiks = validEiks(15);
+ assert.equal(eiks.length, 15);
+ assert.equal(new Set(eiks).size, 15);
+ // Proves the breaker test below actually reaches the network path rather than being filtered out.
+ assert.deepEqual(
+ eiks.filter((e) => e.length !== 9),
+ [],
+ );
+});
+
+test('the circuit breaker aborts a sustained wall of failures', async () => {
+ // A long run against a broken endpoint must stop hammering, even though each individual 5xx is
+ // „transient". Distinct from the 429 path: that stops instantly and deliberately.
+ const c = ctx();
+ try {
+ const many = validEiks(BREAKER_TRIP + 10);
+ const routes = Object.fromEntries(many.map((e) => [e, status(503)]));
+ const h = harness(c, many, routes);
+ assert.equal(await h.promise, 1);
+
+ const attempted = [...new Set(h.calls)];
+ assert.equal(attempted.length, BREAKER_TRIP, 'the breaker cuts the run at its threshold');
+ assert.ok(attempted.length < many.length, 'and therefore short of the full candidate set');
+ // The number that actually matters is REQUESTS, not candidates: each unresolved ЕИК costs the
+ // full retry budget, so the breaker's real cost is BREAKER_TRIP × TRIES_PER_EIK. Keep that under
+ // the ~50 at which the register was observed to start returning a sustained 429 — otherwise the
+ // safety mechanism is itself what trips the block.
+ assert.equal(h.calls.length, BREAKER_TRIP * TRIES_PER_EIK);
+ assert.ok(
+ h.calls.length <= 25,
+ `a failing run must not spend ${h.calls.length} requests before giving up`,
+ );
+ assert.deepEqual(rows(c.dbFile), [], 'a wall of 5xx records nothing');
+ } finally {
+ c.cleanup();
+ }
+});
+
+// ── retention (ADR-0033 decision 5: „a purge step in the same job") ───────────
+test('the job purges past-retention deeds, and does so even when a 429 ends the run', async () => {
+ // Retention is an obligation about other people's data, not a reward for a clean run: the paths that
+ // leave early — a 429, a tripped breaker — are exactly the ones where a naive placement after the
+ // fetch loop would skip it and let third-party names sit on disk indefinitely.
+ for (const [label, route] of [
+ ['clean run', ok(A)],
+ ['429 stops the run', status(429)],
+ ]) {
+ const c = ctx();
+ try {
+ // An old deed with its raw file — well past the 35-day window at the harness's fixed clock.
+ fs.mkdirSync(c.rawDir, { recursive: true });
+ fs.writeFileSync(path.join(c.rawDir, `${C}.json`), '{"owner":"ТРЕТО ЛИЦЕ"}');
+ const db = new DatabaseSync(c.dbFile);
+ db.exec(`CREATE TABLE IF NOT EXISTS deeds (
+ eik TEXT PRIMARY KEY, status TEXT NOT NULL, http_status INTEGER, fetched_at TEXT NOT NULL,
+ raw_path TEXT, body_sha256 TEXT, legal_form_code INTEGER, legal_form_verdict TEXT,
+ seat_normalized TEXT, seat_entry_date TEXT, latest_own_entry_date TEXT,
+ attempts INTEGER NOT NULL DEFAULT 1, outside_reason TEXT);
+ INSERT INTO deeds (eik,status,fetched_at,raw_path) VALUES ('${C}','fetched','2026-01-01T00:00:00Z','${C}.json');`);
+ db.close();
+
+ await harness(c, [A], { [A]: route }).promise;
+
+ assert.equal(
+ fs.existsSync(path.join(c.rawDir, `${C}.json`)),
+ false,
+ `${label}: the past-retention raw deed must be deleted`,
+ );
+ assert.equal(
+ rows(c.dbFile).some((r) => r.eik === C),
+ false,
+ `${label}: its index row must go with it`,
+ );
+ } finally {
+ c.cleanup();
+ }
+ }
+});
+
+test('the purge leaves in-window deeds alone — it is a privacy rail, not a cache eviction', async () => {
+ // If it evicted live cache, every run would re-request deeds it already holds, which is precisely
+ // the volume against the register the pacing exists to avoid.
+ const c = ctx();
+ try {
+ await harness(c, [A], { [A]: ok(A) }).promise;
+ assert.equal(rows(c.dbFile).length, 1, 'the deed just fetched must survive its own job');
+ assert.equal(fs.existsSync(path.join(c.rawDir, `${A}.json`)), true);
+ } finally {
+ c.cleanup();
+ }
+});
diff --git a/scripts/tr/paths.mjs b/scripts/tr/paths.mjs
new file mode 100644
index 00000000..e325d293
--- /dev/null
+++ b/scripts/tr/paths.mjs
@@ -0,0 +1,54 @@
+// Paths and path sanitizers for the Търговски регистър leg (issue #279, ADR-0033).
+//
+// Everything this leg writes lives under scratch/tr/, git-ignored, behind the same refuse-to-run rail
+// the CACBG crawl uses — a deed carries third-party personal data (owner and manager names, the
+// company's street address), so it is ADR-0010 decision 6 territory, extended by ADR-0033 to a second
+// source with a stated retention.
+//
+// scratch/tr/deeds/.json raw response, atomic write
+// scratch/tr/tr-cache.sqlite the index — ЕИК, dates, codes, verdicts. NO names.
+//
+// The constants below are only DEFAULTS for the CLI. Every function that touches the filesystem takes
+// its path explicitly, so tests drive temp directories without mutating process state.
+
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { assertScratchIgnored } from '../cacbg/guard.mjs';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+export const TR_SCRATCH = path.join(ROOT, 'scratch', 'tr');
+export const TR_RAW = path.join(TR_SCRATCH, 'deeds');
+export const TR_DB = path.join(TR_SCRATCH, 'tr-cache.sqlite');
+
+/** Refuse to run unless scratch/tr is git-ignored. Call before the first fetch. */
+export function assertTrScratchIgnored() {
+ assertScratchIgnored('tr');
+}
+
+// A bare ЕИК: 9 or 13 digits, nothing else. Deliberately NOT `path.basename`-normalised — an ЕИК that
+// needed normalising did not come from where we think it did, and silently repairing it is how you end
+// up fetching a different company's deed.
+const EIK_SHAPE = /^(?:\d{9}|\d{13})$/;
+
+/**
+ * Sanitize an ЕИК before it becomes a path segment or a URL segment.
+ *
+ * Returns the value VERBATIM — as a string, always. Bulgarian public bodies carry codes of exactly the
+ * `000…` shape, so a numeric round-trip anywhere on this path silently rewrites the identifier and the
+ * crawler fetches somebody else's deed (R8).
+ *
+ * Shape only. Whether the code's CHECKSUM is valid is a different question with a different remedy,
+ * answered by eik.mjs — conflating the two would report a real-but-invalid code as a path attack.
+ * @param {unknown} eik @returns {string}
+ */
+export function safeEik(eik) {
+ const s = String(eik ?? '');
+ if (!EIK_SHAPE.test(s)) throw new Error(`unsafe ЕИК: ${JSON.stringify(eik)}`);
+ return s;
+}
+
+/** Absolute path of the cached raw deed for an ЕИК, under `rawDir` (default TR_RAW). */
+export function deedPath(eik, rawDir = TR_RAW) {
+ return path.join(rawDir, `${safeEik(eik)}.json`);
+}
diff --git a/scripts/tr/paths.test.mjs b/scripts/tr/paths.test.mjs
new file mode 100644
index 00000000..3401260d
--- /dev/null
+++ b/scripts/tr/paths.test.mjs
@@ -0,0 +1,64 @@
+// node:test — path sanitizers and the refuse-to-run rail for the Trade Register leg.
+//
+// The deed cache holds third-party personal data (owner/manager names, company addresses), so the
+// same rail the CACBG crawl runs behind applies here: everything is written under scratch/, and
+// scratch/ must be git-ignored, asserted BEFORE any fetch. ADR-0010 decision 6 as extended by ADR-0033.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { TR_SCRATCH, TR_RAW, TR_DB, safeEik, assertTrScratchIgnored } from './paths.mjs';
+import { assertScratchIgnored } from '../cacbg/guard.mjs';
+
+test('the TR scratch tree sits under scratch/ and is git-ignored in this repo', () => {
+ assert.ok(TR_SCRATCH.split(path.sep).includes('scratch'), TR_SCRATCH);
+ assert.ok(TR_RAW.startsWith(TR_SCRATCH));
+ assert.ok(TR_DB.startsWith(TR_SCRATCH));
+ assert.doesNotThrow(() => assertTrScratchIgnored());
+});
+
+test('the guard is the CACBG one generalized, not a second copy', () => {
+ // A duplicated safety rail drifts from the original. assertScratchIgnored now takes the
+ // subdirectory, and its existing no-argument callers keep working unchanged.
+ assert.doesNotThrow(() => assertScratchIgnored());
+ assert.doesNotThrow(() => assertScratchIgnored('tr'));
+ // .gitignore ignores `scratch/` WHOLESALE, so every subdirectory of it passes — an unknown name is
+ // not a way to reach the failure branch. Escape scratch/ instead (path.join normalises this to
+ // `docs/.probe`, which is tracked) to prove the guard still refuses when the target is not ignored.
+ assert.throws(() => assertScratchIgnored(path.join('..', 'docs')), /REFUSE TO RUN/);
+});
+
+test('safeEik accepts only a bare 9/13-digit code and returns it verbatim', () => {
+ assert.equal(safeEik('115536179'), '115536179');
+ assert.equal(safeEik('1155361790001'), '1155361790001');
+ // Leading zeros survive — public bodies are exactly this shape, and losing them fetches a
+ // DIFFERENT company's deed.
+ assert.equal(safeEik('000696327'), '000696327');
+});
+
+test('safeEik refuses anything that could leave the intended path or URL', () => {
+ for (const bad of [
+ '',
+ null,
+ undefined,
+ '..',
+ '../115536179',
+ '/115536179',
+ '115536179/../x',
+ '115536179?x=1',
+ '115536179#f',
+ '11553617x',
+ '11553617',
+ '1155361790',
+ 'ЕИК 115536179',
+ ' 115536179',
+ '115536179 ',
+ ]) {
+ assert.throws(() => safeEik(bad), /unsafe/i, JSON.stringify(bad));
+ }
+});
+
+test('safeEik does not validate the CHECKSUM — that is a separate question', () => {
+ // Path safety and identity validity are different concerns: a shape-valid but checksum-invalid code
+ // must still be rejectable by the caller with a specific reason, not conflated into „unsafe path".
+ assert.equal(safeEik('115536170'), '115536170');
+});
diff --git a/scripts/work-staging-schema.sql b/scripts/work-staging-schema.sql
index 9e85b00d..0e9a581d 100644
--- a/scripts/work-staging-schema.sql
+++ b/scripts/work-staging-schema.sql
@@ -159,7 +159,9 @@ CREATE TABLE raw_amendments (
fetched_at TEXT NOT NULL,
seq_no TEXT,
document_number TEXT,
- contract_number TEXT, -- ← link to raw_contracts
+ contract_number TEXT, -- ← link to raw_contracts (rewritten in place by the #306 value resolver)
+ contract_number_raw TEXT, -- #306: the annex-side number before the value resolver rewrote it (provenance; NULL = never rewritten)
+ link_method TEXT, -- #306: 'value_anchor' when the resolver linked this row by value; NULL = matched by number / unlinked
contract_date TEXT,
published_at TEXT, -- amendment publication date (ordering key)
unp TEXT, -- ← link to raw_contracts
@@ -174,6 +176,13 @@ CREATE TABLE raw_amendments (
value_before REAL, -- Стойност преди изменението
value_after REAL, -- Стойност след изменението → current_value
value_delta REAL, -- Изменение на стойността
+ -- #305 Tier-2 text-based value correction (computed in TS ingest, packages/ingest/src/amendment-total.ts):
+ -- value_treatment labels how the основание text reads value_delta ('total_restated' / 'unchanged_restated'
+ -- / 'genuine_increment', NULL when no signal); value_after_restated is the corrected (true) total when a
+ -- double-count was confirmed, else NULL. Derive/normalize use COALESCE(value_after_restated, value_after)
+ -- as the effective after, and skip the arithmetic annex_total_suspect flag when value_treatment IS NOT NULL.
+ value_treatment TEXT,
+ value_after_restated REAL,
currency TEXT,
description TEXT, -- Описание на измененията
reason TEXT, -- Причини за изменение (ЗОП основание)
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 939300bc..bcaee8ac 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -17,6 +17,7 @@
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true,
- "noEmit": true
+ "noEmit": true,
+ "allowImportingTsExtensions": true
}
}