# Refresh cadence master strategy — gov-agreg / vreaudigital.ro
**Data:** 2026-05-11
**Sub-agent:** S1 (refresh cadence master strategy)
**Bază date:** `architools_db` @ 10.10.10.166 — 29 GB
**Cuprinde:** 17 schemas, 2 sub-pipeline-uri (ANAF v9 + ANAF datornici), strategie captcha, monitorizare, idempotență, DR
**Audit-ul de prospețime anterior:** `chatGPT/data-quality/freshness-audit-2026-05-10.md`

---

## 0. Context & constrângeri

| Constrângere | Stare actuală |
|---|---|
| Host orchestrare | `satra` (10.10.10.166), Docker, Ubuntu, **disc la 85% (299/371 GB)** ⚠️ |
| Sistem de scheduling | systemd timers (3 active) + ad-hoc shell wrappers; **nu există crontab agregat pentru toți 13 scraperi** |
| Secrete | Infisical Machine Identity (`/opt/vreaudigital/.infisical-mi`) — refresh per wrapper |
| Anti-pattern interzis | `docker run -e $DATABASE_URL` (leakă via `ps`); folosim `--env-file` 600 + delete |
| Run-as | `bulibasa` (systemd), `root` (cron actual eterra/backup) |
| Captcha sources | ANAF datornici live, Bugetar Faza 2, ANI e-DAI 2022+ (Cloudflare Turnstile) |
| Buget | Mic — 2captcha (~$1/1000), playwright headless OK, headed pe Orchi doar la nevoie |

**Stat actual systemd (verificat azi):**
- `vreaudigital-anaf-daily.timer` → 02:00 zilnic, enrich-anaf.sh tier=daily, concurrency=2
- `vreaudigital-onrc-weekly.timer` → marți 03:00, import-onrc-fresh.sh
- `vreaudigital-mvs.timer` → 04:00 zilnic, refresh-mvs.sh (9 MV-uri seap)

**13 wrappers existente NE-programate prin systemd** (rulează doar manual sau via cron neagregat încă):
`scrape-aaas`, `scrape-aep-donatii`, `scrape-anaf-datornici`, `scrape-ancom`, `scrape-anre`, `scrape-asf`, `scrape-bugetar`, `scrape-cnas`, `scrape-cnsc`, `scrape-curteacont`, `scrape-gnm`, `scrape-regas`, `import-afir-historical`, `import-apia-fermieri`, `import-financials*`.

Audit-ul `scrape_log` confirmă totuși că **toți cei 9 scraperi cu schema dedicată au rulat în ultimele 24h** — deci există un cron ascuns (probabil în `bulibasa` user crontab, nu în `sudo crontab`). Strategia de mai jos **înlocuiește cron-ul ascuns cu un /etc/cron.d/ vizibil + systemd timers per scraper**.

---

## 1. Per-schema cadence table

Coloane: Schema · Sursă (ritm publicare) · Cadență recomandată · Wrapper · Runtime · Risc · Monitor signal (max age tolerat)

| # | Schema | Sursă upstream — ritm | Cadență recomandată | Wrapper | Runtime | Risc | Monitor signal (max age) |
|---|---|---|---|---|---|---|---|
| 1 | **seap.announcements** (WSP) | live | la 4h | `scrape-seap-wsp` (lipsește wrapper!) | 5-15 min | F5 WAF, ASP session | `wsp_sync_state.last_run_at` ≤ 6h |
| 2 | **seap.direct_acquisitions** | live | la 6h | `scrape-seap-da` (lipsește wrapper!) | 10-30 min | session expiry, retry storms | `sync_state[source=da].updated_at` ≤ 8h |
| 3 | **seap.entities + cui_location** | după WSP/DA refresh | seara, după daily | inclus în WSP wrapper | (incl.) | n/a | `entities.fetched_at` ≤ 24h |
| 4 | **anaf** (v9 enrichment — daily delta) | live API | zilnic 02:00 | `enrich-anaf.sh` TIER=daily | 1-2h | rate limit ANAF 503 | `firms.entities WHERE anaf_fetched_at > now-2d` count ≥ 1000 |
| 5 | **anaf.datornici** (data.gov.ro Q) | quarterly | trim 15-ian/15-apr/15-iul/15-oct | `scrape-anaf-datornici` SOURCE=datagov<Q> | 30-60 min | NEW — necesită captcha doar pt live | `anaf.datornici WHERE publication_date > now-180d` ≥ 1 |
| 6 | **anaf.datornici** (anaf.ro live) | live, captcha | trim — **opțional dacă plătim 2captcha** | `scrape-anaf-datornici` SOURCE=live | 2-4h | reCAPTCHA v2 | (decis în §3) |
| 7 | **firms.entities** (ONRC weekly) | săptămânal | marți 03:00 | `import-onrc-fresh.sh` | 30-60 min | bulk diff fail | `firms.entities.updated_at` ≤ 8 zile |
| 8 | **firms.financials** (ANAF bilanțuri) | anual (15-iul publicare an N-1) | 15 iul + 15 aug rerun | `import-financials.sh` | 2-4h | mărime CSV ~3GB | `firms.financials WHERE source_year = year(now)-1` ≥ 800k |
| 9 | **firms.financials_ong / banks** | anual | 20-iul | `import-financials-ong-banks.sh` | 1h | n/a | acelaşi |
| 10 | **fonduri.afir_plati** | anual data.gov.ro | 15-feb (date an N-1) | `import-afir-historical.sh` | 2-4h | CSV mare | `fonduri.afir_plati WHERE source_year = year(now)-1` ≥ 1M |
| 11 | **fonduri.beneficiar_anunt / proiect** (FEADR + FEGA) | live data.gov.ro | săptămânal lun 02:00 | `import-fonduri-beneficiari` (lipsește!) | 15-30 min | n/a | `fonduri.beneficiar_anunt.fetched_at` ≤ 8d |
| 12 | **regas.ajutoare** (Consiliul Concurenței) | lunar | luna 1 ale lunii 02:00 | `scrape-regas` | 10-15 min | n/a | `regas.ajutoare.fetched_at` ≤ 35d |
| 13 | **bugetar.entitate** (mfinante public registry) | lunar | luna 1 ale lunii 03:00 | `scrape-bugetar` | 30-60 min | n/a | `bugetar.entitate.fetched_at` ≤ 35d |
| 14 | **bugetar.executie** (Faza 2 — captcha) | lunar (raportare 30 zile decalaj) | **deferred** — vezi §3 | `scrape-bugetar-executie` (lipsește) | 4-8h pt 1000 entități | captcha + 1000 detail pages | (deferred) |
| 15 | **anre.licente** (3 surse: atestat/electricitate/gaze) | live | zilnic 03:00 | `scrape-anre` SOURCE=all | 3-5 min | TLS cert intermediary | `anre.licente.fetched_at` ≤ 36h |
| 16 | **anre.electricieni** | live (~100k entries) | săptămânal duminică 04:00 | `scrape-anre` SOURCE=electricieni | 30-60 min | pagination volume | `anre.electricieni.fetched_at` ≤ 8d *(when implemented)* |
| 17 | **ancom.operatori + drepturi** | live registry | zilnic 04:00 | `scrape-ancom` | 1-2 min | n/a | `ancom.operatori.fetched_at` ≤ 36h |
| 18 | **asf.entitati** | live (rebuild nightly) | zilnic 05:00 | `scrape-asf` | 2-3 min | "omit g-recaptcha" trick must hold | `asf.entitati.fetched_at` ≤ 36h |
| 19 | **cnsc.decizii** (listing) | live | zilnic 02:30 | `scrape-cnsc` MAX_PAGES=10 (incremental) | 2-5 min | session-based | `cnsc.decizii.fetched_at` ≤ 36h |
| 20 | **cnsc Stage 2** (PDF parse → decision_type) | după listing | săptămânal sâmbătă 02:00 | `cnsc-parse-pdfs` (lipsește) | 4-8h pt 30k | I/O storage PDFs | % decizii `WHERE decision_type IS NOT NULL` ≥ 90% |
| 21 | **cnas.documents** | lunar pe WP media | săptămânal lun 04:00 | `scrape-cnas` | 5-10 min | format CNAS schimbabil | `cnas.documents.fetched_at` ≤ 8d |
| 22 | **cnas.furnizori** (parse din PDF) | inclus în .documents | săptămânal | (incl.) | (incl.) | parser failure 25% | % docs `parse_status='ok'` ≥ 75% |
| 23 | **aaas.firme** | live portal | săptămânal lun 04:30 | `scrape-aaas` | 30s | listă mică (11 firme) | `aaas.firme.fetched_at` ≤ 8d |
| 24 | **curteacont.rapoarte** (Stage 1 listing) | live săptămânal | zilnic 05:30 | `scrape-curteacont` | 1-3 min | n/a | `curteacont.rapoarte.fetched_at` ≤ 36h |
| 25 | **curteacont Stage 2** (detail + PDF + audited CUI) | după Stage 1 | săptămânal duminică 03:00 | `curteacont-detail` (lipsește) | 4-6h pt 1133 | n/a | % rapoarte `WHERE audited_entity_cui IS NOT NULL` ≥ 50% |
| 26 | **aep.donatii_pf/pj/rvc + partide** | trimestrial (raportări) | trim 15-ian/15-apr/15-iul/15-oct + lunar smoke check | `scrape-aep-donatii` | 1h | banipartide.ro mortality | `aep.donatii_pj.fetched_at` ≤ 95d |
| 27 | **ani.declaratii** (PDFs) | live ANI dar **parser ne-implementat** | **deferred** | n/a | n/a | Cloudflare Turnstile | (deferred — multi-week) |
| 28 | **apia.fermieri** (CKAN data.gov.ro) | anual (campania an N publicată 1-mar an N+1) | 15-mar + lunar smoke | `import-apia-fermieri` | 5-10 min | volum mic actual (191 rows — needs investigation) | `apia.fermieri.fetched_at` ≤ 35d |
| 29 | **gnm.comunicate** (RSS) | săptămânal | zilnic 06:00 | `scrape-gnm` | 1-2 min | RSS format change | `gnm.comunicate.fetched_at` ≤ 36h ŞI `publicat_la_max > now-30d` |
| 30 | **gnm.amenzi_extrase** (Stage B fuzzy) | după Stage A | săptămânal duminică 05:00 | `gnm-extract-amenzi` (post-A2) | 30 min | NLP false positives | % comunicate flagged enforcement cu amendă extrasă ≥ 50% |
| 31 | **seap MV refresh** (9 materialized views) | după toate SEAP scrape | zilnic 06:00 (după WSP+DA) | `refresh-mvs.sh` | 5-15 min | dependență de WSP/DA | `mv_authority_concentration` ultim refresh ≤ 26h |

**Note critice:**
- **Wrappere lipsă:** `scrape-seap-wsp`, `scrape-seap-da`, `import-fonduri-beneficiari`, `scrape-bugetar-executie`, `cnsc-parse-pdfs`, `curteacont-detail`, `gnm-extract-amenzi`. Scraperele TypeScript există în `src/`, dar nu au wrapper `cron/scrape-*.sh` cu pattern Infisical MI → env-file → docker run. **Aceasta este lacuna #1 înainte de oricărei programări noi.**
- ANRE rulează deja zilnic via cron ascuns dar nu via systemd vizibil — strategia mută totul în systemd timers per scraper, ca **mvs.timer** azi.

---

## 2. Cron schedule recommendation

Două opțiuni implementabile:
- **(A) /etc/cron.d/govagreg-refresh** — un singur fișier vizibil, ușor de auditat.
- **(B) systemd timers per scraper** — match-uiește patternul existent (`vreaudigital-*.timer`), permite `journalctl -u`, status uniform.

**Recomandare: B (systemd timers)**, pentru că:
1. Patternul există deja (3 timere), iar `journalctl` e mai util decât `/var/log/cron`.
2. Per-unit `OnFailure=` permite alerting nativ.
3. `Persistent=true` reia rulările pierdute după reboot (cron-ul de pe satra nu are anacron).
4. `RandomizedDelaySec=` evită contenția în vârful 02:00-06:00.

### 2.1 Timer skeleton (canonical pattern)

Un template pentru fiecare scraper:

```ini
# /etc/systemd/system/vreaudigital-<scraper>.service
[Unit]
Description=vreaudigital — <scraper> refresh
Wants=network.target docker.service
After=network.target docker.service vreaudigital-prerequisites.service

[Service]
Type=oneshot
User=bulibasa
ExecStart=/opt/vreaudigital/services/seap-scraper/cron/scrape-<scraper>.sh
StandardOutput=journal
StandardError=journal
TimeoutStartSec=4h
OnFailure=vreaudigital-alert@%n.service

# /etc/systemd/system/vreaudigital-<scraper>.timer
[Unit]
Description=vreaudigital — <scraper> at <time>

[Timer]
OnCalendar=<schedule>
Persistent=true
RandomizedDelaySec=600

[Install]
WantedBy=timers.target
```

### 2.2 Recommended schedule (eșalonat pentru a evita contenție pe satra)

```
# === LIVE / NEAR-REAL-TIME (multiple ori pe zi) ===
vreaudigital-seap-wsp.timer       OnCalendar=*-*-* 00,04,08,12,16,20:15:00  # 6× zi
vreaudigital-seap-da.timer        OnCalendar=*-*-* 02,10,18:30:00           # 3× zi (mai greu)

# === DAILY off-peak (02:00-06:00, eșalonat la 5-15 min) ===
vreaudigital-anaf-daily.timer     OnCalendar=*-*-* 02:00:00   # exista (enrich v9 daily)
vreaudigital-cnsc.timer           OnCalendar=*-*-* 02:30:00
vreaudigital-anre.timer           OnCalendar=*-*-* 03:00:00
vreaudigital-curteacont.timer     OnCalendar=*-*-* 03:30:00
vreaudigital-ancom.timer          OnCalendar=*-*-* 04:00:00
vreaudigital-asf.timer            OnCalendar=*-*-* 04:30:00
vreaudigital-gnm.timer            OnCalendar=*-*-* 05:00:00
vreaudigital-mvs.timer            OnCalendar=*-*-* 06:00:00   # exista; mut de la 04:00 la 06:00 ca să fie după toate scraperele

# === WEEKLY (luni 04:00-06:00, sâmbătă/duminică pentru heavy) ===
vreaudigital-cnas.timer           OnCalendar=Mon *-*-* 04:00:00
vreaudigital-aaas.timer           OnCalendar=Mon *-*-* 04:30:00
vreaudigital-onrc-weekly.timer    OnCalendar=Tue *-*-* 03:00:00   # exista
vreaudigital-fonduri-week.timer   OnCalendar=Mon *-*-* 02:00:00
vreaudigital-anre-electricieni.timer  OnCalendar=Sun *-*-* 04:00:00
vreaudigital-cnsc-pdfs.timer      OnCalendar=Sat *-*-* 02:00:00   # Stage 2 heavy
vreaudigital-curteacont-detail.timer  OnCalendar=Sun *-*-* 03:00:00  # Stage 2
vreaudigital-gnm-amenzi.timer     OnCalendar=Sun *-*-* 05:00:00   # Stage B

# === MONTHLY (1 ale lunii, 02:00-04:00) ===
vreaudigital-regas.timer          OnCalendar=*-*-01 02:00:00
vreaudigital-bugetar.timer        OnCalendar=*-*-01 03:00:00
vreaudigital-apia.timer           OnCalendar=*-*-01 04:00:00  # smoke check, full pe martie

# === QUARTERLY (15 ale luni 1/4/7/10) ===
vreaudigital-anaf-datornici.timer OnCalendar=*-01,04,07,10-15 02:00:00
vreaudigital-aep-donatii.timer    OnCalendar=*-01,04,07,10-15 03:00:00

# === ANNUAL ===
vreaudigital-afir-historical.timer  OnCalendar=*-02-15 02:00:00
vreaudigital-financials.timer       OnCalendar=*-07-15 02:00:00
vreaudigital-financials-ong.timer   OnCalendar=*-07-20 02:00:00
vreaudigital-apia-full.timer        OnCalendar=*-03-15 02:00:00

# === DEAD-MAN'S SWITCH (vezi §4) ===
vreaudigital-heartbeat.timer      OnCalendar=*-*-* 07:00:00   # alert dacă lipsesc date proaspete
```

**Total estimat încărcare:** ~35 min CPU/zi steady-state daily slot, ~2-4h/sâmbătă-duminică (heavy stages), ~6-10h în 15 ale lunilor Q (datornici + AEP), ~8h în 15-iul (financials annual).

### 2.3 Resource contention checklist

- **02:00-04:00 daily:** anaf (1-2h) + cnsc (2-5 min) + anre (3-5 min) + curteacont (1-3 min). ANAF rulează long, restul tick-uri scurte cu RandomizedDelaySec=300-600 evită overlap.
- **04:00-06:00 daily:** ancom + asf + gnm + mvs. Toate sub 15 min total. mvs (5-15 min) e ultimul.
- **Luni 02:00-05:00:** fonduri + cnas + aaas + apia smoke. ONRC pe MARȚI ca să nu se ciocnească cu nimic.
- **Weekend:** cnsc-pdfs + curteacont-detail + anre-electricieni + gnm-amenzi. Heavy lifts, niciun overlap.
- **Disc:** la 85% pe satra ⚠️. **Înainte de orice scraper PDF nou (cnsc-pdfs, curteacont-detail) — rezolvă §6 disc**.

---

## 3. CAPTCHA-blocked sources strategy

### 3.1 ANAF datornici live (anaf.ro/restante)

**Stare:** Singura sursă publică bulk (data.gov.ro Q1 2016) e statică. Pentru a actualiza 2016-Q2 → 2026-Q1 (38 trimestre) trebuie scrape live cu captcha.

**Two paths:**

| Path | Cost | Timp implementare | Acoperire |
|---|---|---|---|
| **(a) data.gov.ro Q-snapshots** | $0 | 2 zile (sursa trebuie verificată dacă publică Q-uri noi) | depinde de mfinante |
| **(b) 2captcha pe anaf.ro/restante live** | $1-3/1000 captcha | 1 săptămână + Playwright | toate Q-urile, on-demand |

**Recomandare:** path (a) prima — verifică data.gov.ro listing pentru dataset-uri `anaf-datornici-202X`. Dacă publică, scraperul existent (`SOURCE=datagovYYYY-QN`) deja gestionează. Path (b) doar dacă data.gov.ro nu publică sau e cu lag mare.

**Buget 2captcha pentru path (b) — backfill 5 ani × 4 Q × 1 captcha per fetch = 20 captchas total** (un Q = un download pe full set, nu per-entitate). **Buget: ~$0.10/an** (neglijabil). Costul real: timpul dev pentru integrare Playwright + 2captcha SDK = 2-3 zile.

**Pre-req decision:**
```
DACĂ Q4-2025 publicat pe data.gov.ro
ATUNCI nu plătim 2captcha — extindem `scrape-anaf-datornici.sh` cu SOURCE=datagov2025Q4
ALTFEL plătim 2captcha (~$0.10/an) ȘI investim 2-3 zile dev
```

### 3.2 Bugetar Faza 2 — execuție bugetară per entitate

**Stare:** `bugetar.entitate` = 18,822 entități; `bugetar.executie` = **0 rows**.

**Captcha analiza:** mfinante.gov.ro/static/10/Mfp/sit-Trezor/situatie_trezorerie.html — pagina detail per entitate cere captcha (Google reCAPTCHA v2). Per fetch = 1 captcha.

**Strategie scope:**
- Total entități × 60 luni = 18,822 × 60 = **1,129,320 fetches** dacă acoperim TOATE entitățile × tot istoricul (5 ani).
- Cu 2captcha la $1/1000: **$1,129/total**, ~$226/an pentru 5 ani amortizat.
- Reducem la **top-1000 entități după buget** × 60 luni = **60,000 fetches = $60 total**, ~$12/an. ← **RECOMANDARE**.

**Buget total bugetar Faza 2: $60-100 one-shot pentru top-1000 entități**. Refresh lunar incremental: 1000 × 1 lună = 1000 fetch/lună = $1/lună.

### 3.3 ANI new e-DAI 2022+ (Cloudflare Turnstile)

**Stare:** ANI mută `e-DAI` pe noua platformă (post-2022) protejat cu Cloudflare Turnstile (nu reCAPTCHA). 2captcha **suportă Turnstile** ($3/1000) dar e mai puțin fiabil; Playwright **headed** (cu browser real) e fallback.

**Volum:** ~1.3M PDFs (CLAUDE.md). Chiar la $3/1000 = **$3,900** pentru backfill complet — depășește bugetul. Refresh incremental ~50k/an = $150/an.

**Recomandare:**
- **Faza 0:** parserul PDF nu e implementat încă. Investește 4-6 săptămâni dev în parser ÎNAINTE de a cheltui pe captcha.
- **Faza 1:** scraping curent — folosește **Playwright headed pe Orchi** (RTX A4000, neutilizat noaptea) pentru sample 10k declarații, manual solving / fingerprint rotation. **Cost direct: $0.**
- **Faza 2 (dacă scalează):** 2captcha Turnstile pentru deltas anuale ~$150/an.

### 3.4 ASF "omit g-recaptcha-response" trick

ASF nu necesită 2captcha — scraperul curent omite parametrul `g-recaptcha-response` din POST și serverul răspunde oricum (bug în implementarea ASF). **Risk:** ASF poate fixa oricând acest bug. Monitor: dacă `scrape-asf.sh` începe să returneze 0 rows constant, investighează.

### 3.5 Decision rubric — investim captcha sau nu?

| Sursă | 2captcha cost/an | Valoare unlock | Vot |
|---|---|---|---|
| ANAF datornici live | ~$0.10 | mediu (path-a probabil rezolvă) | **NU prioritar** — verifică path-a întâi |
| Bugetar top-1000 | ~$12 (incremental) | mare (fluxuri bani publici) | **DA** după parser execuție repaired |
| ANI e-DAI 2022+ | ~$150 | flagship | **DEFER** până la parser PDF implementat |
| Bugetar toate 18,822 | ~$226 | mare dar redundant cu top-1000 | **NU** — top-1000 e suficient |

**Buget total 2captcha pe an pentru acoperire completă recomandată:** **$15-25/an** (Bugetar top-1000 incremental + ANAF safety net + ASF backup dacă trick-ul se strică).

**Buget total 2captcha pentru backfill one-shot:** **$60-100** (Bugetar top-1000 × 5 ani istoric).

**Buget extins dacă includem ANI:** **+$150/an pentru deltas**, $3,900 backfill (deferred).

---

## 4. Monitoring & alerting

### 4.1 Dead-man's switch — heartbeat zilnic

**Concept:** o singură query rulează la 07:00 zilnic, verifică `max(fetched_at)` per tabel cheie, alertează dacă > expected_cadence × 1.5.

**Implementare:** `vreaudigital-heartbeat.service` + Brevo SMTP (deja config).

```bash
#!/bin/bash
# /opt/vreaudigital/services/seap-scraper/cron/heartbeat.sh
set -euo pipefail
LOG=/var/log/vreaudigital-heartbeat.log

source /opt/vreaudigital/.infisical-mi
# ... (fetch DATABASE_URL + SMTP creds via Infisical, same pattern as refresh-mvs.sh)

# Define expected freshness (hours)
declare -A EXPECTED=(
  ["seap.announcements"]="6"
  ["seap.direct_acquisitions"]="8"
  ["anre.licente"]="36"
  ["ancom.operatori"]="36"
  ["asf.entitati"]="36"
  ["cnsc.decizii"]="36"
  ["curteacont.rapoarte"]="36"
  ["gnm.comunicate"]="36"
  ["firms.entities"]="192"       # 8 days (weekly cron + buffer)
  ["cnas.documents"]="192"
  ["aaas.firme"]="192"
  ["fonduri.beneficiar_anunt"]="192"
  ["regas.ajutoare"]="840"        # 35 days (monthly)
  ["bugetar.entitate"]="840"
  ["apia.fermieri"]="840"
  ["anaf.datornici"]="4320"       # 180 days (quarterly)
  ["aep.donatii_pj"]="2280"       # 95 days
)

ALERTS=()
for table in "${!EXPECTED[@]}"; do
  schema="${table%.*}"
  tbl="${table#*.}"
  max_age=$(psql -tA -c "SELECT EXTRACT(EPOCH FROM (now() - max(fetched_at)))/3600 FROM ${table}")
  threshold="${EXPECTED[$table]}"
  if (( $(echo "$max_age > $threshold * 1.5" | bc -l) )); then
    ALERTS+=("$table: ${max_age}h stale (threshold ${threshold}h)")
  fi
done

if [ ${#ALERTS[@]} -gt 0 ]; then
  BODY=$(printf '%s\n' "${ALERTS[@]}")
  echo "$BODY" | mail -s "[vreaudigital] heartbeat: ${#ALERTS[@]} schemas stale" \
    -S smtp="smtps://$BREVO_SMTP_HOST:$BREVO_SMTP_PORT" \
    -S smtp-auth=login \
    -S smtp-auth-user="$BREVO_SMTP_USER" \
    -S smtp-auth-password="$BREVO_SMTP_KEY" \
    -S from="alerts@beletage.ro" \
    m.tarau@beletage.ro
fi
```

**Alternativă alerting:** n8n webhook (deja deployed la `https://n8n.beletage.ro`) — POST simplu, n8n trimite mai departe pe Telegram/Slack/email cu un singur workflow.

```bash
curl -fsS -X POST https://n8n.beletage.ro/webhook/vreaudigital-heartbeat \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg body "$BODY" '{type:"stale-data", alerts:$body}')"
```

### 4.2 Per-scraper OnFailure alert

Adaugă `OnFailure=vreaudigital-alert@%n.service` în fiecare timer. Template service:

```ini
# /etc/systemd/system/vreaudigital-alert@.service
[Unit]
Description=vreaudigital alert for %i

[Service]
Type=oneshot
User=bulibasa
ExecStart=/opt/vreaudigital/services/seap-scraper/cron/alert.sh %i
```

`alert.sh %i` extrage ultimele 50 linii via `journalctl -u %i -n 50` și le trimite la n8n webhook.

### 4.3 Top blind spots care necesită monitor azi

1. **`seap.sync_state[source=da].status = pending` din 2025-10-16** (208 zile!) — DA backfill blocat și nimeni nu primește alert. **Trebuie heartbeat dedicat pentru `sync_state` și `wsp_sync_state` care alertează dacă `updated_at < now() - 24h` sau `consecutive_errors > 5`**.
2. **WSP `last_run_at = 2026-05-07`** (4 zile stale, ar trebui la 4h). Patternul deja descris în audit ca lipsit — heartbeat fix-uiește.
3. **Disk 85% pe satra** — heartbeat trebuie să verifice `df -h /` și să alerteze la 90%.

### 4.4 Sample monitor query — copy-paste într-un singur SQL

```sql
SELECT 'STALE: '||t AS alert FROM (
  SELECT 'seap.announcements' AS t, max(fetched_at) AS f FROM seap.announcements
  UNION ALL SELECT 'seap.direct_acquisitions', max(fetched_at) FROM seap.direct_acquisitions
  UNION ALL SELECT 'firms.entities', max(updated_at) FROM firms.entities
  UNION ALL SELECT 'fonduri.afir_plati', max(fetched_at) FROM fonduri.afir_plati
  UNION ALL SELECT 'regas.ajutoare', max(fetched_at) FROM regas.ajutoare
  UNION ALL SELECT 'anre.licente', max(fetched_at) FROM anre.licente
  UNION ALL SELECT 'ancom.operatori', max(fetched_at) FROM ancom.operatori
  UNION ALL SELECT 'asf.entitati', max(fetched_at) FROM asf.entitati
  UNION ALL SELECT 'cnsc.decizii', max(fetched_at) FROM cnsc.decizii
  UNION ALL SELECT 'cnas.documents', max(fetched_at) FROM cnas.documents
  UNION ALL SELECT 'aaas.firme', max(fetched_at) FROM aaas.firme
  UNION ALL SELECT 'curteacont.rapoarte', max(fetched_at) FROM curteacont.rapoarte
  UNION ALL SELECT 'apia.fermieri', max(fetched_at) FROM apia.fermieri
  UNION ALL SELECT 'aep.donatii_pj', max(fetched_at) FROM aep.donatii_pj
  UNION ALL SELECT 'gnm.comunicate', max(fetched_at) FROM gnm.comunicate
  UNION ALL SELECT 'bugetar.entitate', max(fetched_at) FROM bugetar.entitate
  UNION ALL SELECT 'anaf.datornici', max(fetched_at) FROM anaf.datornici
) x
WHERE f < now() - (
  CASE
    WHEN t LIKE 'seap.%' THEN interval '12 hours'
    WHEN t IN ('anre.licente','ancom.operatori','asf.entitati','cnsc.decizii','curteacont.rapoarte','gnm.comunicate') THEN interval '54 hours'
    WHEN t IN ('firms.entities','cnas.documents','aaas.firme','fonduri.afir_plati') THEN interval '12 days'
    WHEN t IN ('regas.ajutoare','apia.fermieri','bugetar.entitate') THEN interval '52 days'
    WHEN t = 'aep.donatii_pj' THEN interval '143 days'
    WHEN t = 'anaf.datornici' THEN interval '270 days'
  END
);
```

Rulează zilnic la 07:00. Dacă returnează rânduri → email/n8n.

---

## 5. Idempotency contract per source

**Cerință:** fiecare scraper TREBUIE să fie idempotent — re-rularea NU duplică, doar refresh `fetched_at`.

| Schema | Idempotency key | Mecanism (din cod existent verificat sau menționat în audit) | Status |
|---|---|---|---|
| seap.announcements | `(source, source_id)` | `ON CONFLICT (source, source_id) DO UPDATE` (confirmat audit) | ✅ |
| seap.direct_acquisitions | similar | similar | ✅ |
| firms.entities | `cui` PK | `ON CONFLICT (cui) DO UPDATE` | ✅ |
| firms.financials | `(cui, source_year)` | UPSERT | ✅ |
| fonduri.afir_plati | `(cnp_cui_hash, source_year, suma)` | hash unique | ✅ (audit) |
| fonduri.beneficiar_anunt | `(announcement_id)` | UPSERT | ✅ |
| regas.ajutoare | `(cui, an, masura)` | UPSERT | ✅ |
| anaf.datornici | `(cui, publication_date)` | `ON CONFLICT (cui, publication_date) DO UPDATE` (confirmat wrapper) | ✅ |
| anaf.lista_alba | TBD | gol — pipeline neimplementat | ⚠️ |
| aep.donatii_pf | `(partid, donator_nume, data, suma)` | composite UNIQUE | ✅ |
| aep.donatii_pj | similar | composite UNIQUE | ✅ |
| aep.donatii_rvc | similar | composite UNIQUE | ⚠️ are date eronate 2034 — necesită cleanup, dar UPSERT funcționează |
| bugetar.entitate | `cif` | UPSERT | ✅ |
| bugetar.executie | TBD | gol | ⚠️ |
| anre.licente | `(source, nr_autorizare)` sau sha1 | UPSERT pe sha1 (wrapper confirmă) | ✅ |
| anre.electricieni | `UNIQUE(nr_autorizare, nume_prenume)` (wrapper) | UPSERT | ✅ (când rulează) |
| ancom.operatori | `cui` | UPSERT | ✅ |
| ancom.drepturi | `(cui, tip_drept)` | UPSERT | ✅ |
| asf.entitati | `cui` | UPSERT | ✅ |
| cnsc.decizii | `(decision_no, decision_year)` | `ON CONFLICT (decision_no, decision_year) DO UPDATE` (wrapper confirmat) | ✅ |
| cnas.documents | `source_url_sha1` | UPSERT | ✅ |
| cnas.furnizori | `(document_id, row_index)` | UPSERT | ✅ |
| aaas.firme | `cui` | UPSERT | ✅ |
| curteacont.rapoarte | `(audit_year, report_no)` sau URL | UPSERT | ✅ |
| apia.fermieri | `(cnp_cui, campania)` | UPSERT | ✅ |
| ani.declaratii | `pdf_sha1` | UPSERT | ✅ (când parser funcționează) |
| gnm.comunicate | `URL sha1` | UPSERT | ✅ |
| gnm.amenzi_extrase | `(comunicat_id, violator_cui, suma)` | UPSERT | ✅ |

**Non-idempotent suspects (necesită review cod):**
- `anaf.lista_alba` — gol, pipeline neexistent. Când implementat, UPSERT pe `cui`.
- `bugetar.executie` — gol. Când implementat, UPSERT pe `(cif, an, luna, indicator)`.
- TED import (`import_ted.py`) — `publication-date` bug confirmat în audit; UPSERT-ul probabil funcționează, dar fix-ul de 1 linie e prerequisite.

**Action item:** după implementarea bugetar.executie și anaf.lista_alba, verifică explicit `ON CONFLICT DO UPDATE/DO NOTHING` în INSERT statements și adaugă teste de idempotență (rulează scraperul de 2 ori la rând și verifică `count(*)` constant).

---

## 6. Disaster recovery

### 6.1 RTO/RPO

**Componente:**
- DB `architools_db` @ 10.10.10.166 — 29 GB
- Codul pe `gitadmin/gov-agreg` Gitea — recuperabil în <1 min
- `.infisical-mi` files — secrets în Infisical, recuperabil cu MI restart
- Cron-uri/timere — în git repo (path `services/seap-scraper/cron/`)

**RTO (Recovery Time Objective):** ~2 ore — git clone + restore dump + restart timers.
**RPO (Recovery Point Objective):** depinde de backup cadence — vezi 6.2.

### 6.2 DB backup status (verified azi)

`sudo crontab -l` pe satra arată **DOAR**:
- `/opt/pug-tracker-scripts/scripts/backup-db.sh` la 03:00
- `/home/bulibasa/backup.sh` la 05:45
- eterra stats email la 06:30

**NU există backup explicit pentru `architools_db`** — trebuie verificat dacă `pug-tracker-scripts/backup-db.sh` sau `bulibasa/backup.sh` include `architools_db`. **Această este o gaură critică în DR**.

**Acțiune imediată:** verifică conținut `/opt/pug-tracker-scripts/scripts/backup-db.sh` și `/home/bulibasa/backup.sh`. Dacă `architools_db` lipsește, adaugă:

```bash
# /opt/vreaudigital/services/seap-scraper/cron/backup-db.sh
#!/bin/bash
set -euo pipefail
BACKUP_DIR=/backups/architools_db
mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d_%H%M)

source /opt/vreaudigital/.infisical-mi
# ... (fetch DATABASE_URL pattern)

# pg_dump custom format (compressed, parallelizable restore)
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \
  --format=custom \
  --jobs=4 \
  --no-owner --no-acl \
  --exclude-table='*staging_*' \
  --exclude-table-data='*log*' \
  --file="$BACKUP_DIR/architools_${DATE}.dump"

# Keep 7 daily, 4 weekly, 12 monthly
find "$BACKUP_DIR" -name 'architools_*.dump' -mtime +90 -delete
```

**Programare:** `vreaudigital-backup.timer OnCalendar=*-*-* 23:00:00` (înainte de scrape-urile de 02:00).

**Mărime estimată:** 29GB DB → ~6-8GB compressed (custom format ratio ~4×). Disc satra: 57GB liberi, suficient pentru ~7 zile retention pe satra + rotate spre **shop** sau **NAS Synology** via rclone/rsync.

### 6.3 Restore procedure (documentată)

```
# 1. Pe satra (sau host nou):
git clone https://git.beletage.ro/gitadmin/gov-agreg.git /opt/vreaudigital
cd /opt/vreaudigital/services/seap-scraper
npm install --omit=optional

# 2. Restore .infisical-mi
scp <safe-source>:/opt/vreaudigital/.infisical-mi /opt/vreaudigital/
chmod 600 /opt/vreaudigital/.infisical-mi

# 3. Restore DB
createdb architools_db
pg_restore --jobs=4 --no-owner --no-acl \
  --dbname=architools_db \
  /backups/architools_db/architools_<latest>.dump

# 4. Restart timers
sudo systemctl enable --now vreaudigital-*.timer
sudo systemctl list-timers | grep vreaudigital
```

### 6.4 Off-site backup

Recomandare: rsync zilnic `/backups/architools_db/` la **shop.avizero.ro:/srv/backups/satra-architools/** sau spre Synology NAS dacă există. **NU rsync direct la GitHub/Gitea** (29GB > limit).

```
# /etc/systemd/system/vreaudigital-backup-offsite.timer OnCalendar=*-*-* 23:30:00
rsync -avz --delete /backups/architools_db/ shop:/srv/backups/satra-architools/
```

---

## 7. Recommended action items, prioritized

### 7.1 This week (low effort, high ROI)

| # | Item | Effort | Impact |
|---|---|---|---|
| 1 | **Fix TED `publication-date` field** în `import_ted.py` (1-line) | 5 min | 100% TED publication_date populated |
| 2 | **Reset `seap.sync_state[source=da].status` din pending → null** + relansare backfill DA | 15 min | unlock 208-day-old backfill (potential ~8M rows) |
| 3 | **Investigate WSP stall** — `wsp_sync_state.last_run_at = 2026-05-07`. Verifică cron-ul ascuns; dacă lipsește, creează `vreaudigital-seap-wsp.timer` per §2.2 | 1h | live SEAP daily feed restored |
| 4 | **Verifică backup DB** — citește `/opt/pug-tracker-scripts/scripts/backup-db.sh` și `/home/bulibasa/backup.sh`. Dacă `architools_db` lipsește, instalează `backup-db.sh` din §6.2 | 1h | DR readiness, RPO ≤ 24h |
| 5 | **Implementează `vreaudigital-heartbeat.timer`** din §4.1 + 1 query în §4.4 | 2h | dead-man's switch peste 17 schemas |

**Total week 1:** ~5h work, unlocks 4 critical paths.

### 7.2 This month (medium effort)

| # | Item | Effort | Impact |
|---|---|---|---|
| 1 | **Creează wrappere lipsă** pentru `scrape-seap-wsp`, `scrape-seap-da`, `import-fonduri-beneficiari`, `gnm-extract-amenzi`, `curteacont-detail`, `cnsc-parse-pdfs` (6 wrappere cu pattern Infisical MI) | 1 zi | uniformizează scheduling |
| 2 | **Migrează toate cele 13 wrappere existente la systemd timers vizibili** per §2.2 (înlocuiește cron-ul ascuns) | 1 zi | observabilitate `journalctl -u`, retry on failure |
| 3 | **Investigate ANAF datornici Q4 2025 publicare pe data.gov.ro** — dacă publicat, rulează `scrape-anaf-datornici SOURCE=datagov2025Q4`. Altfel începe integrare 2captcha | 1 zi | datornici devine fresh |
| 4 | **Disc cleanup pe satra** — staging tables 3GB (firms.staging_onrc_*) + log rotation + offsite backups să poată fi instalate | 4h | disc < 80%, room pentru cnsc PDFs Stage 2 |
| 5 | **CUI matcher rerun pentru cnas.furnizori, apia.fermieri, fonduri.beneficiar_proiect** (3 schemas cu 0% match) | 4h | unlock cross-source recipes |

### 7.3 Next quarter (high effort sau lower priority)

| # | Item | Effort | Impact |
|---|---|---|---|
| 1 | **CNSC Stage 2 PDF parser** — extract decision_type/summary pentru 29k decizii | 1-2 săpt | decizii filtrabile |
| 2 | **Curtea Conturi Stage 2** detail-page + audited_cui + PDF | 2 săpt | rapoarte legate la CUI |
| 3 | **Bugetar.executie Faza 2** + 2captcha pentru top-1000 entități (~$60 one-shot) | 2 săpt | flux financiar public |
| 4 | **ANI declaratii parser** (1.3M PDFs) — recommended deferred până confirmat parser ANRE/AAAS minor backlogs cleared | 4-6 săpt | flagship politicieni |
| 5 | **SEAP DA backfill 2017-2024** (~8M rows) — post DA sync_state reset | 2-3 săpt | acoperire achiziții directe completă |

---

## Anexa A — Snapshot scrape_log azi (2026-05-11)

| Schema | Last successful run | OK runs 7d |
|---|---|---:|
| aaas | 2026-05-10 17:51 | 6 |
| aep | 2026-05-09 20:58 | 4 |
| ancom | 2026-05-10 18:06 | 3 |
| anre | 2026-05-10 14:47 | 3 (4 errors) ⚠️ |
| apia | 2026-05-10 18:53 | 1 |
| asf | 2026-05-10 18:19 | 1 |
| cnas | 2026-05-10 18:08 | 67 (multiple PDF parses) |
| cnsc | 2026-05-10 19:19 | 4 |
| gnm | 2026-05-10 19:02 | 5 |
| **seap.wsp_sync_state** | **2026-05-07 03:01** (3 zile stale!) | n/a |
| **seap.sync_state[da]** | **2025-10-16** (208 zile stale!) | n/a |

**Concluzie:** 9 din 11 schemas live au rulat în ultimele 24h. SEAP WSP + DA sunt blind spots — heartbeat trebuie să le acopere explicit.

---

## Anexa B — Quick reference: existing systemd timers (current state)

```
/etc/systemd/system/vreaudigital-anaf-daily.timer   → 02:00 daily   → enrich-anaf.sh TIER=daily
/etc/systemd/system/vreaudigital-onrc-weekly.timer  → Tue 03:00     → import-onrc-fresh.sh
/etc/systemd/system/vreaudigital-mvs.timer          → 04:00 daily   → refresh-mvs.sh
```

**Recomandare:** păstrează aceste 3 ca-s sunt, adaugă alte 18-20 timere pentru a acoperi celelalte schemas.

---

**Strategy doc complete.** Implementation poate începe imediat cu §7.1 items.

---

## Anexa C — AEP donatii (banipartide.ro): lag pattern confirmat 2026-05-12

**Verificare directă a sursei** (`https://www.banipartide.ro/app/json.php?mode=dt&ssid=<base64-SQL>`):

| Dataset | Total rânduri sursă | Max an pe sursă | DB rânduri | DB max an / max `data_donatie` |
|---|---:|---|---:|---|
| Donatori PJ (Monitorul Oficial 10k+) | 3,612 | **2024** (114) | 3,567 | 2024 / 2024-12-13 |
| Donatori PF (Monitorul Oficial 10k+) | 30,792 | **2024** (1,859) | 30,173 | 2024 / 2024-12-27 |
| RVC (Rapoarte Venituri/Cheltuieli) | 353,473 | **2023** (42,791) | 346,237 | 2023 / 2034-01-31 (erori OCR) |

**Concluzie:** sursa **NU are date 2025 sau 2026**. Ultima rulare a cron-ului (2026-05-11 09:15 satra) a importat deja toate rândurile existente (`seen=3612/30792/353473`). Diferența DB vs sursă (45/619/7236 rânduri) e dată de:
- PJ: 572 rânduri cu `data_donatie IS NULL` (multi-date strings ca `"11.10.2019; 13.11.2019"`) — parser-ul nu reține `an` în acele cazuri.
- PF: similar, 9,268 NULL pe `data_donatie`.
- RVC: 7,236 skip-uri pe upsert (rânduri cu format date neparsabil în limba română, ex. `"septembrie 2019"`).

### De ce nu există 2025/2026 pe sursă

**Mecanism legal (Legea 334/2006 + HG 10/2016):**
- Partidele politice raportează **donațiile peste 10× salariu minim** la AEP, care le publică în **Monitorul Oficial Partea I-A**.
- Termen legal: până la **30 aprilie anul N+1** pentru donațiile anului N (raport anual venituri/cheltuieli).
- Pentru campanii electorale: raportare separată în 15 zile de la finalul campaniei.
- Expert Forum (proiectul banipartide.ro) scanează MO, parsează PDF-urile și actualizează tabelul cca 1-3 luni după publicare.

**Calendar așteptat:**
| Date donații | Raport AEP în MO | Apariție pe banipartide.ro | Estimare disponibilitate gov-agreg |
|---|---|---|---|
| 2024 (anuale) | apr 2025 | mai-aug 2025 | ✅ deja în DB |
| 2025 (anuale) | apr 2026 | **mai-aug 2026** | 🕒 fereastră **acum** (mai 2026) – aug 2026 |
| 2026 (anuale) | apr 2027 | mai-aug 2027 | 🕒 mai 2027+ |
| 2024 campanii electorale (PE, prezidențiale, locale, parlamentare) | 15-30 zile post-campanie | 1-3 luni mai târziu | ✅ în DB la `data_donatie` apropiat de turul de scrutin |

**Notă RVC:** Rapoartele anuale de venituri/cheltuieli (RVC) sunt mai lente — 2023 a apărut probabil în 2025. Așteptăm 2024 pe sursă în **iunie-octombrie 2026**.

### Recomandare de cadență (revizuită)

Cron actual `vreaudigital-aep-donatii.timer` = 1 ale lunii la 03:30 (= **lunar**, mai des decât §1 #26 care zicea quarterly). Asta e **OK pentru fereastra mai-august 2026** când e cel mai probabil să apară 2025 — îl prinde la prima rulare.

**Nu schimbăm cadența**. Heartbeat-ul (§4.1) ar trebui să fie tolerant la **95 zile** stale (cum e setat), pentru că între ianuarie-aprilie nu apare nimic nou și asta e normal.

### Next check

Următoarea verificare automată **15 iunie 2026** (~o lună după aceasta) — dacă sursa tot nu publică 2025, alarmă falsă; dacă publică, cron-ul de 1 iulie 03:30 va prinde inserțiile. Verificare manuală opțională: `curl` aceeași SQL ca aici, `python3 -c "..."` pentru count years.

