# WORKLOG.md — append-only journal of work blocks

> Purpose: a new agent (or the same agent after a context loss) reconstructs
> the timeline from this file + REWORK-STATE.md WITHOUT reading history.json.
> Newest entries at the bottom. Never rewrite old entries — append only.
> Checkpoint ritual (tests → commit → state → worklog → sync) is defined in
> REWORK-STATE.md §9 and the ENV section of the task prompt.

---

## 2026-10-16 (session 1, reconstructed from history.json)

- 17:41 — Sandbox probed (Debian 12, Python 3.11, Node 20, git, network OK).
  Workspace created; REWORK-STATE.md started as single source of truth.
- 17:50 — Legacy sources downloaded from https://iya.ru/qwen_access/
  (172 files, sha256 verified, 0 mismatches) → `legacy/iya-ru-source/`.
  Root causes of the "internal IP" defect located (raw XFF trust, nginx
  listen 192.168.31.31, UNIQUE(ip) destroying history, CDN deps, public
  phpinfo). Decision: rewrite in PHP 8.5-target syntax (8.2-compatible),
  zero composer deps, no CDN. PHP 8.2 installed via apt for tests.
- 18:00–19:00 — P0/P1: Core (Config/Container/Bootstrap/Version), Db (PDO,
  portable upsert, checksummed migrations), Logger, IpUtil/ClientIp/
  ClientIpResolver (the IP fix, 32 tests), self-hosted Inter/JetBrains Mono
  font subsets.
- 19:00–20:30 — P2: HTTP core (Request/Response/Router/Kernel/Session/CSRF/
  SecurityHeaders), migrations 001–004 (77 tables / 141 indexes / 6 triggers
  / 2 FTS5), `bin/iya` console, dependency-free test runner.
- 20:30–21:00 — P2b: design system (tokens/components/utilities, 5 CSS files),
  marketing/error/docs pages, `scripts/smoke_http.py` (149 live checks green).
- 21:00–21:20 — P3 identity: AuthService (register/login/lockout/remember-me
  rotation/2FA/TOTP+recovery), fixes found by tests: rate_limits.created_at
  missing, limiter key mismatch, AuthResult data/errors swap.
  **143 tests / 803 assertions green.** Synced to backup.
- 21:20–21:32 — P5 started: `src/Domain/Visit/PrivacyPolicy.php` written
  (295 lines, 4 modes as server-side allow-lists). Next write_file
  (`src/Security/ThreatDetection.php`) **died mid-call** — context exhausted.
  Session lost; user requested restore (messages 621–626 unanswered).

## 2026-10-17 (session 2 — restore + P5 core)

- Restore: sandbox snapshot found in /mnt/oss (41 tarballs; newest
  9dc82704…, Oct 16 21:32) → tree + history.json + TASK-PROMPT recovered.
  Tree → /home/user/iya-platform (+ docs/TASK-PROMPT-ORIGINAL.md),
  PHP reinstalled via apt, `.git` was lost in snapshot → re-init, baseline
  commit `0ce3f26`. Health check: 143/803 green (matches interruption point).
- P5 core implemented (the interrupted block, completed):
  - `src/Security/ThreatDetection.php` — 13 categories, severity scoring
    (2/3/5/7), thresholds suspicious≥5 blocked≥12, caps (8 KB/field,
    64 KB/request, 16 KB body), ~0.04 ms clean request. Flag-never-drop.
  - `src/Domain/Visit/VisitService.php` — capture() hot path (payload →
    PrivacyPolicy::apply BEFORE rest → VisitQueue), persist() worker path
    (request_id idempotency, geo enrichment, visitor state, status
    resolution, column-safe insert), annotate/annotateOrDefer/applyDeferred
    (beacon-vs-drain race), recordConsent (cookie + consent_records upsert +
    deferred row annotation), anonymous mode → rollups day counters.
  - `src/Web/Controllers/TrackerController.php` — /tracker/collect
    (GET pixel + POST beacon, FIELD_MAP validation), /tracker/consent
    (POST record + GET state); routes added to tenant zone; apex consent
    route GET→GET+POST.
  - `public/web/js/tracker.js` — consent+extended-gated, capability flags &
    engagement only (no fingerprinting), sendBeacon/keepalive, vid/sid ids.
  - `src/Http/Kernel.php` — captureVisit() in finalize() (skip: swarm zone,
    authenticated, 3xx, non-capturable paths/static; forwards
    response_status/time + context->link target); Context.skipVisitCapture
    for canonical/maintenance early returns.
  - `src/Workers/MaintenanceWorker.php` — real deps for enrichment;
    deferred task routing with retry-vs-ack semantics.
  - View::baseScripts() loads tracker.js when TRACKER_ENABLED.
- Contract decision (ADR-013): `ip_internal` observed server-side (trusted
  proxy/socket) does NOT require consent — it is part of the unconditional
  IP-fix requirement; only browser-reported (WebRTC) internals are
  consent-gated. Removed from PrivacyPolicy::CONSENT_REQUIRED_FIELDS.
- Bugs found & fixed: Db::upsert stale lastInsertId + no-id tables
  (rate_limits) → 500 on repeated consent POST (regression test added);
  pixel Content-Length hardcoded; deferred tasks must ack on permanent
  policy refusal, retry only while the row is missing.
- Verified: **196 tests / 1158 assertions**, lint 108 files/0, smoke
  149/149, live HTTP cycle (page→queue→worker→visits row; DNT→minimal;
  XSS→suspicious served 200; probe→hack_type=probe on 404; consent→cookie
  .iya.ru + deferred annotation; queue depth 0 after drain).
- Commits: `0ce3f26` baseline, `39abaf7` P5 core. Sync + release tarball
  `/home/user/iya-platform-p5-20261016.tar.gz`.
- Next per REWORK-STATE §9: **P4 links + redirect engine**.

## 2026-10-17 (session 2 — recovery protocol adopted)

- User approved continuation and asked for a recovery-prompt addendum;
  written into the chat and adopted for this session: backups that must
  survive live under /home/user (snapshot-persisted); /workspace mirrors are
  volatile scratch. `scripts/sync.sh` now also writes versioned tarballs to
  `/home/user/backup/versions/` (rotated at 20). WORKLOG.md created (this
  file). Large files are written in chunks ≤200 lines per call.

## 2026-10-17 (session 2 — P4 core: links + redirect engine)

- New code (all chunked ≤200 lines/write):
  - `src/Domain/Subdomain/SubdomainService.php` — name normalisation,
    availability (RFC1035 label rules + reserved list), memoised findByName,
    isReachable (status gate).
  - `src/Domain/Link/LinkService.php` — CRUD + materialised paths
    (normalizePath/validatePath, depth ≤ 6, `TENANT_RESERVED` first-segment
    list — `team` deliberately ALLOWED per the spec example), per-action
    validation (safe http(s) targets only, webhook → SSRF check), password
    hashing, JSON columns, availability matrix (enabled/schedule/limits/
    visibility/password+`_unlocked`), atomic registerClick, trash
    (soft delete/restore with clash detection/purge), real-diff audit trail
    with secret redaction.
  - `src/Domain/Link/RedirectEngine.php` — the hot path: exact lookup →
    `$context->link` on EVERY outcome (refusals included) → password gate
    (CSRF-gated POST, session unlock) → availability → targeting rules
    (12 kinds, 8 operators, lazy probe, ReDoS-guarded regex) → A/B stable
    bucketing (crc32(ip|ua), impressions/clicks) → action dispatch
    (redirect 302/301, page/card/message with escaped markdown or
    DOM-sanitised HTML, file realpath-confined to uploads, webhook
    SSRF-guarded + HMAC-signed, stats → /stats, qr/form/iframe → pending)
    → click counter.
  - `src/Support/Markdown.php::sanitizeHtml` — DOM whitelist sanitizer
    (drops script/iframe/handlers/javascript: URLs, forces rel=noopener).
  - `src/Support/Seeder.php` — `bin/iya seed [--fresh]`: demo owner +
    organization.iya.ru + 12 links covering every action/gate + variants +
    targeting rule + demo upload file.
  - Templates `src/Web/Templates/links/{page,gone,password,pending,webhook-result}.php`
    (public layout, existing component CSS, i18n keys added: +18 en/ru).
  - Wiring: `TenantController::resolve` (501 stub → engine), Bootstrap
    factory RedirectEngine, Kernel: `Context->sessions/csrf` published,
    captureVisit forwards `redirect_reason` from the engine AND now captures
    3xx (link redirects are the core product event; canonical/maintenance
    are excluded via skipVisitCapture).
- Bugs found & fixed this block:
  - prepareRow never persisted `action` → every link silently became
    'redirect' (caught by engine tests: 500s on page/file actions).
  - audit() `$actor['type']` without null-coalesce → silent audit loss.
  - Routes::RESERVED_PATHS was too broad for link paths (contained `team` —
    the spec's own example path); replaced by narrow TENANT_RESERVED.
  - webhook test semantics: with target_url set the engine redirects after
    firing (by design) — test now uses a target-less webhook link.
  - **Sandbox path-mapping gotcha (documented in §9)**: the display path
    `/home/user` is remapped ONLY in bash command text; literals inside file
    contents are not — runtime scripts must derive paths (sync.sh now
    derives PERSIST_VERSIONS from its own location; heredoc-written python
    anchors broke until moved into a write_file-created script).
- Verified: **223 tests / 1294 assertions / 0 failures** (+27 tests:
  LinkServiceTest 15, RedirectEngineTest 12), lint 119 files/0, smoke
  149/149, live HTTP matrix on seeded organization.iya.ru: redirect 302
  (+X-Iya-Link), markdown page/card 200, password flow (form→403 wrong→200
  correct→session unlock), expired 410, one-time & click-limit gates, A/B
  stable bucket, device targeting (/app vs /web), file attachment 200,
  webhook→302 /thanks, qr pending 200, unknown 404; visits rows carry
  link_id + redirect_reason incl. refusals; counters exact.
- Next per §9: P4 remainder (links dashboard UI, QR encoder, import/export,
  bulk ops) → P6 public stats → P7 dashboard.

## 2026-10-17 (session 2 — P4 block A: QR encoder)

- `src/Infrastructure/Qr/` — pure-PHP QR encoder (ISO 18004 Model 2, byte
  mode, versions 1..10, ECC L/M/Q/H, all 8 masks + 4-rule penalty scoring),
  zero dependencies:
  - `QrCode` — GF(256) tables, RS generator/encode, bit-stream builder
    (mode+counter+terminator+0xEC/0x11 pad), block split/interleave, matrix
    assembly (finders/separators/timing/alignment/dark module), data zigzag,
    BCH(15,5) format + BCH(18,6) version info, mask selection in **test mode**
    (format areas zeroed — the Oishi/python-qrcode behaviour; scoring with
    real bits picks different masks).
  - `QrMatrix` — rows(+quiet zone), `toSvg()` (single run-merged <path>,
    crispEdges), `toPng()` (pure writer: 8-bit grayscale, zlib, CRC32 chunks —
    no GD needed), `checksum()` for golden tests.
- Verification (the important part):
  - `scripts/verify_qr.py` + `scripts/qr_dump.php`: **63/63 cases identical
    module-by-module to python-qrcode** (every version 1..10 × L/M/Q/H +
    URL/mailto/vCard/Unicode/1-byte payloads). Dev-only (pip), not in the suite.
  - Real-scan proof: generated PNGs (incl. ECC H/L, scales 6..10) decoded by
    **ZBar via pyzbar** → exact canonical URLs.
  - `tests/unit/Infrastructure/QrCodeTest.php`: golden checksums, structure
    invariants (finders/timing/dark module/size=4V+17), BCH known-answers
    (L/M/Q/H mask-0 format words, version words 7/8/10), RS root-divisibility
    + the classic V1-M "HELLO WORLD" vector, PNG chunk CRC validation,
    exceptions (too long, bad ECC), SVG/quiet-zone sanity.
- Two spec bugs found by verification and fixed: format-info placement was a
  90° rotation of the reference layout (all diffs confined to row/col 8);
  dark module belongs at (size-8, **8**), not column 6.
- Wiring: `qr` link action → `links/qr` page (QR of target URL, else of the
  link's canonical URL so scans always land on the platform); tenant route
  `/qr/{path}` (HTML page | `?raw=1` image/svg+xml | `?format=png&scale=&ecc=`
  attachment); RedirectEngine::canonicalLinkUrl() helper; +3 i18n keys en/ru.
- Verified: **238 tests / 1599 assertions / 0 failures**, lint 125 files/0,
  live: /qr action page 200 (inline SVG), /qr/{path} HTML/raw/png 200 with
  correct Content-Type + Disposition, unknown → 404.
- Next: P4 block B — dashboard links CRUD UI (LinksController), then
  import/export + bulk ops, then P6 public stats.

## 2026-10-17 (session 2 — P4 block B: dashboard links UI)

- `src/Web/Controllers/Dashboard/LinksTrait.php` (mixed into DashboardController;
  16 stub methods removed): owner-scoped links list w/ filters (q/action/trash)
  + pagination, create/edit forms → LinkService, show (QR + canonical + gates),
  soft delete/restore, duplicate (disabled copy, path-suffix dedupe), audit
  view, bulk enable/disable/delete, export CSV+JSON (attachment), import JSON
  (envelope-tolerant, 500-row cap, imported links start disabled), QR
  endpoint (svg page | ?raw=1 | ?format=png&scale=&ecc=). Transport-only:
  every rule lives in the domain layer.
- `src/Web/Templates/layouts/dashboard.php` — app shell (sidebar nav, topbar,
  user chip, logout POST, theme/lang partials; inline CSS allowed by the CSP
  `style-src 'unsafe-inline'`; wraps `content` between header/footer sections
  of layouts/base). Mobile: single column.
- Templates `dashboard/links/{index,form,show,audit,import}.php`: table with
  per-row status badges (expired/scheduled/limit/disabled), check-all bulk bar,
  action-aware form (JS shows only the fields of the selected action;
  password field only for visibility=password), datetime-local ↔ UTC,
  QR panel on show, redacted audit diff view. +48 i18n keys (en/ru, 970 total).
- Live-verified over HTTP (demo@iya.ru seeded owner), 13/13 + import battery:
  login→list (shell ✓, seeded rows ✓), create→302 show (QR svg + canonical ✓),
  update, audit (created+updated), delete→trash→restore, export json/csv
  attachments, bulk disable (is_enabled=0 in DB), qr png (valid magic),
  CSRF-less POST→403, anon→302 /auth/login; import: message action ✓,
  bad slug rejected, javascript: URL rejected, duplicate path skipped
  (exactly one `team/vasiliy_ivanov` row in DB). Smoke 149/149, lint 132/0,
  238 tests / 1599 assertions green.
- Fixed along the way: layout logout pointed at unrouted /dashboard/logout
  (→ /auth/logout); import initially dropped `content` (message links failed
  validation → now mapped); sandbox note — bash heredocs rewrite
  '/home/user' literals (use write_file+edit_file for scripts touching paths).
- P4 polish left (small): CSV import parser, purge UI+route, collections UI,
  targeting/A-B editor UI (engine + tables already support both).
- Next per §9: **P6 public stats** (TenantController::index/stats/statsJson,
  PrivacyPolicy::publicVisibility + maskVisit are ready), then P7 dashboard.

## 2026-10-17 (session 3 — P6: public subdomain page + stats policy engine)

- `src/Domain/Analytics/PublicStatsService.php` — the policy engine + read
  models. Layered server-side gates: tenant visibility (private/owner_only/
  password) → stats_public master switch → stats_public_until time box →
  stats password → publish delay (cutoff; owner exempt) → field switches via
  PrivacyPolicy::publicVisibility. Read models: totals (7 counters, 30-day
  window), byDay series (gap-filled), top(country/device/browser/os/referer/
  link), recentVisits (masked rows, public column allow-list — no hashes/
  consent/threat detail ever leaves), publicLinks. **Hidden/private links are
  excluded from public views entirely — lists AND aggregates AND tops**
  (SQL EXISTS filter). Owner always sees everything.
- `TenantController`: index (branding + QR of the domain + counters + links +
  visits teaser), stats (full page: counters, server-rendered SVG bar chart,
  top-N cards with share bars, paginated masked visits table), statsJson
  (agent envelope + visibility meta; machine-readable gate codes 403/410),
  statsUnlock (POST gate=stats|tenant, CSRF, session unlock), links
  (catalogue), about (owner card when published). statsStream stays 501 → P7.
- Templates: `tenant/{index,stats,stats-hidden,password,links,about}.php`,
  shared `partials/visits-table.php` (columns follow the visibility switches)
  and `partials/bar-chart.php` (pure SVG, no JS/CDN, CSP-safe). +35 i18n keys
  (en/ru, 1000 total).
- Bug found live: SQL alias `returning` is reserved in SQLite → totals query
  500'd; renamed alias. Test bug: static closure without $this; device-top
  expectation (bot visit is desktop too).
- Verified: **247 tests / 1637 assertions / 0**, lint 142/0, smoke 149/149,
  live battery on organization.iya.ru: index/stats/stats.json/links/about 200
  with real data (40 visits, 9 uniques, 6 bots, 1 llm, 1 suspicious; tops;
  chart bars; masked rows), policy matrix live (stats off → 403, expired →
  410, password wrong → 403 / correct → session unlock → 200, delay 24h →
  fresh visits absent, hidden link → gone from lists+aggregates, show_ip=0 →
  column removed). New unit suite PublicStatsTest (9 tests).
- Next per §9: **P7 dashboard analytics + realtime SSE**, then P8 reports;
  P4 polish (CSV import, purge, collections, targeting/AB UI) alongside.

## 2026-10-17 (session 3 — P7: dashboard analytics + realtime SSE)

- `src/Http/SseResponse.php` — time-boxed SSE transport (30 s window, client
  auto-reconnects with Last-Event-ID; poll-callback source; heartbeat every
  15 s; X-Accel-Buffering:no; flushes through ob layers; connection_aborted
  aware). Works on php-fpm and the built-in server; no deps.
- `src/Domain/Analytics/OwnerStatsService.php` — owner-scoped read models
  (NO public masks, hidden links included): totals (12 metrics incl.
  flagged/blocked/tracked/avg_time/avg_scroll), gap-filled byDay, top() over
  11 dimensions, visits explorer (q/status/bot/hack/range filters + count +
  page, joins link path), visitById (full row), realtimeSince (id-cursor +
  window), latestVisitId, onlineNow (distinct COALESCE(visitor_hash, ip,
  request_id)).
- `Dashboard/AnalyticsTrait` (mixed into DashboardController; 9 stubs removed):
  index (range presets via dateRange()), visits, visitShow, visitJson,
  realtime (+extraScripts realtime.js), realtimeStream (SSE with hello-cursor
  protocol: fresh connections announce the tail id, reconnects resume from
  Last-Event-ID), subdomain (settings), subdomainAvailability (JSON probe),
  subdomainStore (create → 302 /dashboard/links/new; update appearance).
- `TenantController::statsStream` — public SSE: policy-gated (403 JSON when
  stats hidden or visits_list off), events masked field-by-field per the
  owner's switches.
- Templates: dashboard/{index,visits,visit,realtime,onboarding,subdomain}.php
  (counter grids, tops with share bars, filters bar, 9-group visit detail with
  JSON pretty-print, live feed table); `public/web/js/realtime.js`
  (EventSource client: hello/visit/stats/end handlers, pause on hidden tab,
  row cap 50, flash animation). +44 i18n keys (en/ru → 1042).
- Kernel: `/stats/stream` + `/stats.json` added to non-capturable paths
  (reconnects must not create junk visits); `SSE_WINDOW_SEC` config key.
- Bugs found & fixed: onboarding template required `$apex` that three
  AnalyticsTrait entry points did not pass (500 for subdomain-less users —
  template made defensive); `onlineNow` counted only visitor_hash (fixtures/
  minimal-mode rows have none → COALESCE fallback); test bugs (time()-2 is
  seconds not days; uniques arithmetic).
- Verified: **257 tests / 1671 assertions / 0** (+10: OwnerStatsTest incl.
  SSE frame format), lint 152/0, smoke 149/149. Live: onboarding cycle 7/7
  for a fresh user (register→verify→login→form→availability→create→
  testcorp.iya.ru live, duplicate name rejected); demo owner: overview/
  explorer/detail(8 groups)/json(127 cols)/realtime page; **SSE proved live**:
  hello + 4 visit events pushed during parallel traffic + stats frames;
  tenant /stats/stream → 200 text/event-stream.
- Next per §9: **P8 reports & exports** (14 report pages on OwnerStatsService
  + pure-PHP XLSX/PDF writers), then P4 polish, P12 i18n batch, saved views.

## 2026-10-17 (session 4 — P8: analytics reports + exports)

- `src/Domain/Analytics/ReportsService.php` — report data builders (every
  result doubles as the HTML descriptor and the JSON payload): heatmap
  (7×24 UTC + peak), UTM (5 dimensions), bots (names/vendors/shares),
  suspicious (by threat type + recent flags with query samples), links
  (LEFT JOIN aggregates), subdomains (per-account), uniques series,
  **retention** (weekly cohorts, Monday-anchored, W+0..W+7 %, future weeks
  null), **funnels** (session_id step transitions, top-20, honest empty state
  when no consented sessions), **anomalies** (z-score vs window mean/σ,
  threshold 2.0, min 5 visits), **compare** (current vs equally-sized
  previous period, % deltas).
- `src/Infrastructure/Export/` — three pure writers:
  - `CsvWriter` (RFC4180, UTF-8 BOM for Excel),
  - `XlsxWriter` (minimal OOXML via ext-zip: 6 parts, inlineStr cells,
    numeric/bool typing, bold header style, XML-illegal control chars
    stripped, 32767-char cell cap),
  - `PdfWriter` (hand-assembled PDF 1.4: A4 pages, Helvetica/WinAnsi with
    Cyrillic transliteration (documented limitation), striped rows, repeating
    header, page footers, exact xref offsets).
- `Dashboard/ReportsTrait` (AnalyticsController; 37 stubs removed): generic
  descriptor pipeline (kpis/bars/series/table/matrix/heatmap/text) + 18 thin
  HTML methods + 18 JSON twins + `export/{csv,xlsx,pdf,json}` (filtered by
  the explorer's q/status/bot/hack/range, 50k cap). Self-contained
  `reportSubdomain()` (no LinksTrait dependency).
- `dashboard/report.php` — one template renders all 18 reports: switcher
  chips, range presets, export menu, heatmap grid with per-cell tooltips,
  cohort matrix with %-heat cells. +23 i18n keys (1088 total).
- Incident log (cost ~30 min): trait-vs-trait method collision avoided by
  splitting P7's AnalyticsTrait (dashboard) and P8's ReportsTrait (analytics)
  — one write_file had OVERWRITTEN the P7 trait (restored from git
  `ec9fc1c`); ReportsTrait lost its `reportSubdomain()` in a silent str.replace
  no-op (caught by live 500s, added via edit_file); a test battery had
  positional args swapped (req(path, method) called as req(method, path))
  producing "Malformed HTTP request" disconnects — the server was innocent.
- Verified: **275 tests / 1762 assertions / 0** (+18: ReportsServiceTest 10,
  ExportWritersTest 5 incl. xlsx round-trip through ZipArchive and PDF
  xref-offset validation, +fixes), lint 160/0, smoke 149/149, live: 18/18
  HTML + 18/18 JSON + retention/anomalies/compare spot checks + 4 exports
  (xlsx validated with python zipfile+minidom, pdf with pypdf).
- Next per §9: **P9 agent-swarm API** (SwarmApiController: agents/sections/
  threads/messages/tasks/knowledge/features, GET-first, SHA-256 tokens),
  then P4 polish and P10-P16.

## 2026-10-17 (session 4 — IP intelligence: org/ASN + async AbuseIPDB)

- User request (inline with "continue"): show the organisation behind the
  visitor IP ("Org AS42610 OJSC National Cable Networks") + AbuseIPDB
  enrichment **asynchronously** (queue at IP sight, worker fills in), with
  admin settings: key input, collection checkboxes, display checkboxes,
  request rates.
- `migrations/sqlite/005_ip_intel.sql`: `ip_intel` (PK ip, ip_hash for
  post-anonymisation joins, abuse_score/isp/domain/hostnames/reports/
  country/asn/raw/sources/fetched_at) + `ip_intel_queue` (UNIQUE(ip) dedupe,
  attempts/backoff) + 9 `ipintel.*` settings rows (INSERT OR IGNORE).
- `src/Security/IpIntelService.php`: settings layer over the `settings`
  table (env ABUSEIPDB_KEY as headless fallback); `consider(ip)` enqueue
  gates (enabled+key, valid/public/non-documentation — LAN/reserved/TEST-NET
  never leave the server; re-check window; daily cap); `fetchAbuseIpDb`
  (v2 /check, maxAgeInDays=90, 6 s timeout, typed errors for 401/429);
  `parseIntel` honours the collect checkboxes (uncollected → null: operator
  decides retention); `drainQueue` with per-minute + per-day budgets,
  linear backoff, drop@5, **401 drops immediately**; `orgLine()` display
  formatter (visit geo → intel isp → domain); `abuseTier()`; test seam:
  injectable fetcher (no network in tests).
- Wiring: `MaintenanceWorker` — `consider()` right after each persisted
  visit + new `ip_intel` task; Bootstrap factory; `OwnerStatsService::
  listVisits` LEFT JOINs ip_intel (abuse_score/isp/domain) and selects
  geo_asn/geo_asn_org/geo_isp; `PublicStatsService::recentVisits` exposes
  geo_asn* (masked by the owner's geo switch like the rest).
- Admin UI: `/admin/settings` (AdminController::settings/saveSettings stubs
  replaced) — write-only key field (never echoed; CLEAR button), enable,
  6 collect checkboxes, 2 display switches, 4 rate/budget inputs, live usage
  line (today/minute/queue); auditLog on save (key change flagged, key never
  logged). Template `admin/settings.php`.
- Display: dashboard visits table — org line under IP + `abuse N` badge at
  ≥ min_score_flag; visit detail — accent "IP intelligence" card (org, tier
  badge, ISP/domain/country/reports/last-report/hostnames/fetched);
  public tenant pages — org line under IP only when `show_public_org` AND
  owner IP/geo switches allow; abuse scores are dashboard-only.
- +34 i18n keys (en/ru → 1100). `.env`: ABUSEIPDB_KEY fallback,
  ADMIN_REQUIRE_2FA=false for dev (prod stays true — demo admin has no 2FA).
- Tests: `IpIntelServiceTest` (16): disabled/keyless inert, private+doc
  ranges never queued, dedupe via UNIQUE, re-check window, daily cap,
  AbuseIPDB payload mapping + score clamping, collect-checkbox retention,
  drain store/ack, retry→drop@5, per-minute budget, bad-key immediate drop,
  orgLine matrix (incl. the exact "AS42610 OJSC National Cable Networks"),
  abuse tiers, settings round-trip. One test-fixture bug fixed (helper
  defaults reset enabled=false between seeds).
- Live proof (dummy key): anon visit with XFF 8.8.8.8 → row(proxy, link 13)
  → worker auto-queued → **real AbuseIPDB HTTPS call → 401 → dropped at
  once**, log carries ip_hash only; seeded intel+ASN → dashboard org line +
  abuse badge + detail card (777 reports, dns.google) + public org line
  toggling both ways; abuse never public. 291 tests/1834 assertions, lint
  163/0, smoke 149/149.
- Next per §9: **P9 agent-swarm API**, then P4 polish, P10-P16.

## Block P9 — agent-swarm API (2026-10-17)

**Done:** the full GET-first agent-swarm API per docs/AGENT_API.md.
- `src/Domain/Swarm/SwarmService.php` (~2.2k lines): registration idempotent by
  agent_id (secrets shown once, SHA-256 hashes only at rest), Bearer/?token auth
  incl. rotated tokens via agent_tokens, rotate/recover, sections (depth ≤ 6
  domain-enforced + configurable SWARM_MAX_DEPTH, materialised paths, per-parent
  slug uniqueness, visibility gates public/agents-only/private, versioned
  snapshots), threads (auto default discussion thread), messages (client_message_id
  replay → meta.replayed, sha256 body checksum, FTS5 search + LIKE fallback,
  versioned edits, soft deletes, ±1 votes with retraction, mentions → inbox),
  tasks (leased claims w/ expiry reclaim + max_attempts, claimant-only
  transitions, blocked-requires-note, handoff, approvals_required flow,
  dependency edges → blocked_by, one-call context bundle, full task log),
  knowledge (content-hash dedup → meta.duplicate, versions, usefulness ratings,
  markdown export), features (propose/vote-with-recalc/comment, public roadmap),
  agent ratings (one per rater+target+task), swarm_audit on every call with
  redacted secrets.
- `src/Agents/SwarmApiController.php` rewritten from 125 stubs: standard
  envelope {ok,data,meta}/{ok:false,error{code,message,details,request_id}},
  per-bucket rate limits (RATE_LIMIT_SWARM_REGISTER/WRITE/READ "limit:window"),
  format=html rendering, openapi.json (3.0.3, 40+ paths), docs/rules/health/
  status/index meta endpoints. Routes converted to GET+POST (54).
- Config knobs wired: SWARM_MAX_DEPTH, SWARM_TOKEN_TTL_DAYS,
  SWARM_ALLOW_ANONYMOUS_REGISTER, SWARM_SECTION_MAX_PER_AGENT,
  SWARM_MESSAGE_MAX_BYTES, SWARM_ENABLED.
- **Schema lesson:** nearly every agent reference column in 002_swarm.sql is
  INTEGER → agents(id) (NOT the TEXT agent_id) — tokens, votes, ratings,
  mentions, authors, assignees; CHECK enums differ from the doc in places
  (threads kind has no 'default'/'topic'; tasks kind 'generic' not 'general';
  deps kind 'blocks'; votes ±1 only — 0 means retract). Projections map numeric
  ids back to public agent_id strings via agentName().
- **Response lesson:** `Response` defaults `Content-Type: text/html` unless the
  header key is exactly `Content-Type` (case-sensitive) — lowercase keys
  silently produced HTML-typed markdown/redirect responses.
- Tests: `tests/unit/Domain/SwarmServiceTest.php` (23 tests, 349 assertions);
  live journey `scripts/smoke_swarm.py` (53 checks, positional port arg;
  idempotent against a persistent dev DB via per-run suffixes).

**Health:** 314 tests / 2183 assertions / 0 failures · lint 165 files / 0 ·
smoke_http 149/149 · smoke_swarm 53/53 · commit `c612fa3`.

> Note (same block): one combined `tar + git + sync + tests` invocation ended with a
> transient PDOException from Db.php:504 during the suite; an immediate re-run with
> zero code changes was green (314/2183) and stayed green. Treated as filesystem
> contention from the tarball/backup step running just before; watch for repeats.

## Block RECOVERY + P8.6 start — abuse reporting plan (2026-10-17)

**Recovery:** sandbox had been reset; `"$ARENA_WORKSPACE"` was empty. Restored the project
from the newest oss snapshot `/mnt/oss/02b59141-...tar.gz` (contained
`workspace/iya-platform` + the p6/ipintel versioned tarballs). Reinstalled
php-cli/sqlite3/mbstring/xml/curl/intl/zip, re-init'ed git (`.git` does not
survive), committed baseline `76542fd`, `php bin/iya migrate` (all migrations
already applied — skipped), health check **314 tests / 2183 assertions / 0
failures**, lint **165 files / 0** — matches the §0 checkpoint exactly.
Copied the two versioned tarballs into `"$ARENA_WORKSPACE"/backup/versions/`.

**New user requirement (P.S. 2026-10-17):** a settings checkbox for reporting
detected attacks and spam mail to AbuseIPDB, plus an intake path for the
operator's own mail server, which will hand the platform the spammer's IP and
details. User offered either an HTTP endpoint or a MySQL table polled every
minute and asked which is more convenient.

**Decision (ADR-013):** build both, endpoint-first. The platform DB is SQLite,
so a MySQL-only intake would add a pdo_mysql runtime dependency and couple the
app to an external schema; the HTTP endpoint `POST /api/v1/intel/spam-report`
(shared secret) is real-time, testable and self-contained. The MySQL drop-box
poller stays as an optional fallback for firewalled mail servers
(`SPAM_INBOX_DSN`), running on the existing maintenance worker pass (~1 min).
Both paths converge on one `spam_reports` table, idempotent by
`UNIQUE(ip, evidence_hash)`. Outbound reporting reuses the proven
ip_intel_queue worker pattern: durable queue, per-minute + daily budget, dedupe
window (AbuseIPDB rejects the same IP within 15 min), sent-log, retry with
backoff, drop at 5 attempts / bad key.

Placed as phase **P8.6** ahead of P4 polish because it is a live user request.

## Block P8.6 — AbuseIPDB reporting + spam intake (2026-10-17)

**Done:** the full feature from the user's P.S., endpoint-first per ADR-013.
- Migration `006_abuse_reports.sql`: `spam_reports` (inbound, idempotent by
  UNIQUE(ip, evidence_hash)), `abuse_report_queue` (UNIQUE(dedupe_key) absorbs
  races + doubles as the re-report guard), `abuse_report_log` (ip_hash-only
  audit, budget counters, dedupe-window lookup), 8 new `ipintel.report_*/inbox_*`
  settings rows.
- `src/Security/AbuseReportService.php` (~600 lines): threat→category mapping
  (sql_injection/xss/…→18, rce/deserialization/ssrf→15+18, webshell→15,
  probe→16), spam slug mapping (email_spam→11 default, phishing→7, …),
  severity gate (default ≥5=HIGH — scanner noise never auto-reported), private/
  reserved/documentation addresses refused on EVERY path, comment sanitising,
  budgeted drain (min(rate_per_min, daily_cap) from the sent-log), 72 h dedupe
  window, 900 s backoff on provider 429, drop at 5 attempts or bad key,
  `pollMysqlInbox()` for the optional MySQL drop-box (claims status='new',
  marks done/error, skips cleanly without pdo_mysql/DSN), secret helpers
  (hash_equals, regenerate → 48-hex, env fallback INTEL_INBOX_SECRET).
- `IntelController` + apex route `GET|POST /api/v1/intel/spam-report`
  (X-Intel-Secret or Bearer; JSON + form bodies; 404 inbox_disabled /
  401 / 422 invalid_report with reason; throttles 60/min + 10 auth-fails/5min;
  GET = self-describing contract). Registered in the Kernel controller list —
  **lesson: the Container does NOT autowire; new controllers must be added to
  the foreach in Kernel::__construct** (cost a 500 in the first live smoke).
- MaintenanceWorker: tasks `abuse_reports` + `spam_inbox`; the visit drain now
  keeps `persist()`'s id and calls `queueFromVisitPayload($payload, $visitId)`.
- Admin → Settings: second card (checkboxes report_attacks/report_spam/
  inbox_enabled, severity select, 3 budgets, write-only secret + REGENERATE
  with one-time reveal via ?secret=1 + CLEAR), audit-log detail extended,
  i18n en/ru (~24 keys each).
- Docs: `docs/ABUSE-REPORTING.md` (204 lines — pipeline diagram, settings
  table, endpoint contract, curl/rspamd examples, MySQL DDL + polling rules,
  mapping tables, ops tips), docs/README index row, openapi path + 2 new error
  codes, `.env.example` (INTEL_INBOX_SECRET, SPAM_INBOX_*).

**Tests/proof:** `AbuseReportServiceTest` 20 tests (mapping, gates, dedupe,
budgets, retry/drop, intake validation/idempotency, secrets, poller skip,
source refs) — suite **334/2333 green**, lint **168/0**. smoke_http +15 intake
checks = **164/164**; smoke_swarm **53/53**. `scripts/_admin_settings_check.py`
live admin journey **24/24**; `scripts/_e2e_abuse_reporting.py` outbound E2E
**9/9** — real SQLi request → flagged visit → queued → worker → **real
AbuseIPDB HTTPS call → 401 (dummy key) → dropped + logged**, spam intake →
queued → same proof; both scripts restore dev settings afterwards.
**Test-fixture lesson:** never use TEST-NET ranges (203.0.113.x, 198.51.100.x)
as "public" fixtures — IpUtil::isDocumentation() refuses them by design.

**Health:** 334 tests / 2333 assertions / 0 failures · lint 168/0 ·
smoke_http 164/164 · smoke_swarm 53/53 · commit `9e0360e` (state: next commit).

**Next per §9:** P4 polish (CSV import, purge UI, collections UI, targeting/AB
editor UI), then P7 saved views, then P10.

## Block P4 polish (1-2/4) — CSV import + trash purge (2026-10-17)

**CSV import (commit `d9e3e47`):**
- `LinkService::parseCsvLinks()` — RFC 4180 parser (fgetcsv over php://memory):
  BOM strip, case-insensitive header matched against the new IMPORT_COLUMNS
  whitelist (superset of the export columns → exports round-trip), quoted
  commas/newlines, blank-line skip, unknown columns ignored, tags as JSON or
  pipe-list, booleans 1/0/true/yes/on, maxRows guard.
- `linkImport` controller: auto-detect json|csv (or ?format=), shared
  `importItemToData()` mapper (full column whitelist, imported links always
  start DISABLED), per-reason skip counters in the flash (`links.import_reason_*`
  en/ru), >500-row truncation warning.
- **Kernel bug fixed (platform-wide):** `finalize()` drained the flash queue on
  EVERY response — including the 302 that just queued it — so PRG flashes were
  silently lost everywhere. Now drains only on rendered (non-3xx) responses.
- app.js: `data-confirm` on the submitter button now wins over the form-wide
  one (the admin settings CLEAR/REGENERATE confirms were dead before).
- 9 unit tests + live proof `scripts/_import_check.py` 10/10 (login → form →
  CSV with quoted comma + pipe tags + unlisted visibility/max_clicks/one_time →
  rows created disabled; headerless CSV → flash + nothing created).

**Trash purge (commit `380b294`):**
- Routes: `POST /dashboard/links/{id}/purge` (trash-only; live links refuse
  with `links.purge_not_trashed`), `POST /dashboard/links/trash/purge`
  (empty trash), bulk actions extended: +restore, +purge (purge skips
  not-deleted rows even if selected).
- `LinkService::purgeTrashed()` (owner-scoped, LIMIT 1000, returns count).
- **Schema truth discovered:** `link_audit` cascades with the link, so the old
  purge() audit insert was an FK violation swallowed by its own catch —
  removed; the durable purge trace now goes to the platform `audit_log`
  (`link.purged`, `links.purge_all`) from the controller. Children survive
  (parent_id SET NULL); visits keep link_id (no FK) — analytics history intact.
- UI: per-row × button with confirm, trash toolbar (bulk restore/purge),
  header "Empty trash" with strong confirm; i18n en/ru (10 keys).
- 3 unit tests (cascade, child detach, trash-only scoping) + live proof
  `scripts/_purge_check.py` 14/14 (single/bulk/empty-trash, live-link guard,
  audit rows, unrelated links spared).

**Health:** 346 tests / 2388 assertions / 0 failures · lint 168/0 ·
smoke_http 164/164 · smoke_swarm 53/53.

**Next:** P4 polish 3-4/4 — collections UI (`collectionStore` stub), then
targeting/A-B editing UI.

## Block P4 polish (3/4) — collections UI (2026-10-17)

**Done (commit `ab46cf1`):**
- `src/Domain/Link/CollectionService.php`: create/update/delete (owner-scoped),
  slugify with Cyrillic transliteration (bug found+fixed: uppercase letters
  missed the map — 'Команда' → 'omanda'; now lowercased first), per-user slug
  dedupe (name, name-2, …), colour normalisation (#abc → #aabbcc, invalid →
  null), `setLinkCollection()` guards (foreign link/collection, trashed link),
  `purge`-free deletes: links survive via FK ON DELETE SET NULL, `detached`
  count returned; `listForUser()` with live link_count (trashed not counted).
- Routes: POST /dashboard/collections/{id} (update), POST …/{id}/delete;
  stubs in DashboardController replaced by a new CollectionsTrait.
- UI: `dashboard/collections.php` (create card + table with inline rename /
  colour picker / description / count badge → filtered links / delete with
  confirm), link form collection select (+ "create first" hint), links index
  filter `?collection=<id>|none` (select + pager keeps the param), dashboard
  nav entry. `LinkService::prepareRow` learned collection_id (''/0 → NULL);
  `listForOwner` + `countOwnerLinks` filter support.
- i18n en/ru: 18 keys (collections.*, links.collection*).
- Bootstrap: CollectionService factory.

**Tests/proof:** CollectionServiceTest 8 tests (slugify, colour, dedupe,
fields, owner scoping, link guards, detach-on-delete, counts+filters) — suite
**356/2444 green**, lint **172/0**; live `scripts/_collections_check.py`
**19/19** (full journey: create → rename/recolour → assign via link form →
filter → delete detaches); smoke_http **164/164**, smoke_swarm **53/53**.

**Next:** P4 polish 4/4 — targeting/A-B editing UI (engine + tables ready).

## Block P4 polish (4/4) — targeting/A-B editor (2026-10-17)

**Done (P4 is now COMPLETE):**
- `LinkService`: RULE_KINDS/RULE_OPERATORS constants (mirror the DB CHECKs),
  `listRules/saveRule/deleteRule/toggleRule` (owner-scoped, validates kind,
  operator, value≤500, action ∈ ACTIONS, target via `isSafeTarget` — scheme
  tricks like javascript:/data: rejected; private IPs stay allowed for
  redirects exactly like create() — the browser navigates, no SSRF),
  `listVariants/saveVariants` (replace-set semantics: rows with a known id
  update and KEEP impressions/clicks, rows without id insert, `delete` flag or
  absence removes, empty name/target draft rows ignored, unsafe target aborts
  the whole save; writes `links.ab_test={"enabled":bool}` — auto-disabled when
  no variants remain).
- Routes (5): GET `/dashboard/links/{id}/targeting` + POST rules / rules/{rid}/
  toggle / rules/{rid}/delete / variants. Controller methods in LinksTrait with
  ownedLink guards + flashes.
- Template `dashboard/links/targeting.php`: rules table with per-row inline
  edit (form-attribute pattern — no <form> inside <tr>), add-rule form with the
  value-format cheat sheet in the hint, variants card (name/target/weight/
  control/impressions+clicks/delete + 2 spare rows, ab_enabled checkbox).
  Link show page got a "Targeting & A/B" button. i18n en/ru (~27 keys).
- **Live engine proof** `scripts/_targeting_check.py` 18/18: cookie rule
  `abtest=ru` overrides the redirect for a matching visitor (302 → override,
  no cookie → default), toggle-off stops the override, invalid kind refused,
  one 100%-weight variant captures everyone once ab_enabled, impressions tick,
  rule delete + variant delete-flag restore the default target. **Script
  lesson:** per-host cookie jars — a shared jar let the tenant-host response
  overwrite the authenticated apex session and silently broke CSRF posts.

**Health:** 358 tests / 2478 assertions / 0 failures · lint 173/0 ·
smoke_http 164/164 · smoke_swarm 53/53.

**P4 status: ✅ COMPLETE** (core+UI+CSV import+purge+collections+targeting/AB).
**Next per §9:** P7 saved views (viewStore/views/viewDelete stubs in
AnalyticsTrait; views table exists), then P10 teams/webhooks/notifications.

## Block P7 — saved views (2026-10-17)

**Done (P7 is now COMPLETE):**
- `src/Domain/Analytics/SavedViewService.php`: filter whitelist
  (range/q/status/bot/hack/link_id — absolute from/to deliberately NOT
  storable so views stay relative and never go stale; unknown range →
  last_30_days, q clamped to 64, enums validated), create/update/delete,
  **single default slot** (pinning clears the previous), cap 50/user,
  owner-scoped everywhere, list sorted default-first-then-name, Bootstrap
  factory.
- `BaseController::dateRange()` refactored: the resolution logic moved to a
  pure `dateRangeFromParams(array $params, $default)` — saved views apply by
  merging their stored preset under the explicit query params.
- `AnalyticsTrait::visits()`: ?view=<id> pins a view, ?view=none opts out,
  bare /dashboard/visits with zero filter params auto-applies the default
  view; explicit params always beat the view values; passes savedViews +
  activeView to the template.
- `AnalyticsController`: the three stubs (views/viewStore/viewDelete) replaced
  + new viewUpdate (rename, set_default); viewStore accepts filters_json or
  individual fields, return_to via safeRedirectTarget. Routes: the analytics
  group is mounted at **/analytics** (apex root — NOT /dashboard/analytics;
  cost a 404 round in the live check). POST /analytics/views/{id} added.
- UI: views bar on the visits explorer (chips with ★ for default, "No view"
  reset, inline save form posting the current state, Manage link);
  `dashboard/views.php` manager (count/cap badge, rename inputs, filters as
  badges, make-default, apply, delete with confirm). i18n en/ru 19 keys
  (inserted in place — do NOT re-sort these catalogs, they are grouped, not
  alphabetical).

**Tests/proof:** SavedViewServiceTest 8 tests (sanitiser matrix, name/filters
validation, default single-slot, owner scoping, rename+cap, sort, JSON round-
trip) — suite **366/2508 green**, lint **176/0**; live `scripts/_views_check.py`
**17/17**: save from a filtered URL → apply (select locked, q carried, chip
active) → explicit param beats view → view=none clean → pin default → bare
visits applies it → rename → delete → clean. smoke_http **164/164**,
smoke_swarm **53/53**.

**Next per §9:** P10 teams/webhooks/notifications (Notification domain
scaffolded), then P11 admin remainder, P12 i18n batch.

## Block P10a — notifications pipeline (2026-10-17)

**Done (commit `ce209f0`):**
- Migration `007_notifications.sql`: `user_notification_prefs` (per-channel
  switches, kinds whitelists, telegram chat + binding handshake columns) and
  seeds for `notification_templates` (email_verify, password_reset, welcome,
  password_changed, system — en+ru, {placeholder} bodies).
- `src/Infrastructure/Mail/Mailer.php`: MAIL_DRIVER log|sendmail|smtp —
  full stdlib SMTP client (EHLO/STARTTLS/AUTH LOGIN/DATA, multiline reply
  parsing, dot-stuffing, RFC 2047 subjects, multipart/alternative builder),
  socket injectable for tests. `TelegramClient`: sendMessage via Bot API with
  transport seam; gated by TELEGRAM_ENABLED + token.
- `OutboxDispatcher` (was a stub): email DIRECT form {to,subject,text,html}
  and TEMPLATE form {template,locale,user_id,...vars} (renders via
  notification_templates with locale→en→fallback chain, resolves the
  recipient from users), telegram, webhook (signed: X-Iya-Signature
  sha256=HMAC(secret,body), X-Iya-Event, X-Iya-Delivery; updates webhooks
  health stats). Retry policy: 60 s × attempts, 15 min on 429, failed at 5 or
  on poison errors; on a webhook's 5th strike bumps failure_count and, from
  the 3rd dead delivery, warns the owner via an inbox notification.
- `Notifier` (was a scaffold): inbox row always + preference-gated email/
  telegram fan-out through the outbox; kinds whitelist ('*' wins); telegram
  binding handshake (6-digit code → chat, hash stored, 15 min TTL, single
  use; chat change resets confirmation); inbox queries (unread/list/markRead
  — direct SQL, `read_at = NULL` never matches in SQL — /markAllRead/remove).
- UI: bell with unread badge in the dashboard topbar (Kernel shares
  notifications_unread), `/account/notifications` — channel prefs card
  (email kinds checkboxes, telegram chat + send-code/confirm flow, platform
  switches surfaced), test-send button, inbox table (mark read / mark all,
  pagination). Routes: prefs/test/telegram send-code/confirm added.
- Events wired: register → welcome (inbox), password reset → password_changed
  (inbox + channels per prefs). BaseController::notify() helper —
  fire-and-forget, never breaks the flow.
- Mailer/TelegramClient lost `final` (anonymous-subclass seams in tests);
  .env.example += MAIL_SENDMAIL_PATH.

**Tests/proof:** NotificationTest 10 + OutboxDispatcherTest 7 + MailerTest 9
(smtp dialogue over stream_socket_pair — preloaded canned server replies) —
suite **396/2642 green**, lint **182/0**. Live `scripts/_notifications_check.py`
**19/19**: bell, page, test-send → inbox + outbox → worker → sent + mail.log,
unread badge, mark-all-read, email-off gating, register → welcome +
verification email queued. smoke_http **164/164**, smoke_swarm **53/53**.

**Next:** P10b — event webhooks (account/webhooks stubs: list/create/delete/
test + event emitters: link.*, visit.* → outbox 'webhook'), then P10c alerts,
P10d comments, P10e teams.

## Block P10b — event webhooks (2026-10-17)

**Done:**
- `src/Domain/Notification/WebhookService.php`: docs/API.md contract — 7-event
  catalogue (visit.new/unique, link.created/limit_reached/target_down,
  security.suspicious, agent.task), create() with static SSRF validation
  (honours SSRF_BLOCK_PRIVATE; metadata endpoints/ports refused), event
  whitelist, cap 20/user, auto 64-hex secret, owner-scoped find/toggle/
  delete/deliveries, toggle-reset-failure_count on re-enable, `emit()` cheap
  no-op when no hooks (the common case), `sendTest()` (webhook.test event).
- `OutboxDispatcher` webhook path aligned with the docs: exponential backoff
  (60 s × 2^n, cap 1 h; 15 min flat on 429), **every attempt logged to
  webhook_deliveries** (event, response code/body, attempt, duration_ms,
  error), after the 5th strike the hook is **disabled** and the owner gets a
  webhook_failing notification (inbox+channels). Body envelope now
  {event, delivered_at, data}.
- Emitters: `LinkService::create` → link.created (lazy WebhookService, wrapped
  in try/catch — webhooks never break the write path); `VisitService::persist`
  → visit.new always, visit.unique when is_unique, security.suspicious on
  is_hack/blocked status, link.limit_reached on redirect_reason=click_limit
  with a **1-hour per-link dedupe** (one warning, not one per refused hit).
  Hot-path cost control: subdomain→users index of enabled hooks cached 60 s
  per VisitService instance; no hooks → zero extra queries per visit.
  Privacy: emitted fields come from the already-masked visit row.
- AccountController: webhooks/createWebhook/deleteWebhook/testWebhook stubs
  replaced + new toggleWebhook (+route). `account/webhooks.php`: create form
  (url + event checkboxes), per-hook cards (state badges active/failing(n)/
  disabled, event badges, masked secret with one-time reveal via ?secret=id,
  test/toggle/delete with confirms, last delivery line, recent-deliveries
  table). i18n en/ru 31 keys. Bootstrap factory. docs/API.md §Webhooks
  updated (emitter status + webhook.test note).
- **Live-script lesson (again!):** per-host cookie jars are MANDATORY — with a
  flat jar the owner's session rode along to the tenant host and
  `captureVisit()` correctly skipped it ("owners are not traffic"), so no
  visit.new was queued. Anonymous tenant hit → queued instantly.

**Tests/proof:** WebhookServiceTest 8 tests (validation matrix incl. metadata
IP, cap, owner scoping, emit matching/kill-switch, toggle reset, test event,
cascade) + dispatcher delivery-log/disable assertions — suite **404/2712
green**, lint **185/0**. Live `scripts/_webhooks_check.py` **25/25 ×2 runs
stable**: UI, refused local URL, secret reveal/mask, link.created emit via
CSV import, visit.new emit via anonymous tenant hit, **real HTTPS deliveries
to example.com → 405 logged with body excerpt + attempt + duration**, retry
backoff, test button, disabled hook emits nothing. smoke_http **164/164**,
smoke_swarm **53/53**, notify live **19/19**.

**Next:** P10c alerts (alerts/alert_events tables + account.alerts stubs +
worker evaluator firing into Notifier/WebhookService), then P10d comments,
P10e teams.

## Block P10c — threshold alerts (2026-10-17)

**Done:**
- `src/Domain/Notification/AlertService.php`: the alerts/alert_events tables
  (001_core) finally wired. Evaluated kinds: **traffic_spike** (current window
  vs the previous-24 h average window × multiplier, min_visits floor),
  **traffic_drop** (current < baseline × (1−drop_pct), min_baseline floor),
  **suspicious** (is_hack=1 or status suspicious/blocked), **new_bot**
  (is_bot), **llm_agent** (is_llm_agent), **limit_reached** (enabled links at
  click_count ≥ max_clicks, payload lists up to 10). Declared-but-deferred:
  target_down (needs the health checker), new_member (P10e), agent_task
  (swarm follow-up), custom — `fire()` returns 'skipped' for them and the UI
  marks them "coming soon" (disabled options).
- Conditions sanitised per kind with clamps (window 5..1440 min, multiplier
  1.1..100, drop 10..100 %); channels ⊆ inbox|email|telegram; cooldown
  300..86400 s; cap 25/user; owner-scoped CRUD.
- Firing: at most once per cooldown (`last_fired_at` gate); every fire writes
  an `alert_events` row (severity, payload incl. title/body, notified flag)
  and notifies the owner via Notifier kind `alert_fired` on the alert's
  channels (email/telegram go through prefs + outbox). Suppressed evaluations
  are NOT recorded (minute-by-minute firehose otherwise).
- Worker task `alerts` → `evaluateAll()` (≤200/pass, error-isolated per
  alert). Window queries ride `ix_visits_sub_time`.
- AccountController: alerts/createAlert stubs replaced + toggleAlert/
  deleteAlert (+2 routes). `account/alerts.php`: create form (kind select,
  window/min_count/multiplier/drop inputs — the service keeps only the
  relevant ones, no JS needed), channel checkboxes, cooldown select; alert
  cards with conditions summary, state badge, channels, last fired, pause/
  delete, recent events `<details>`. i18n en/ru 39 keys. Bootstrap factory.

**Tests/proof:** AlertServiceTest 12 tests (validation/scoping/cap, toggle,
per-kind evaluation on seeded visits incl. window boundary + baseline maths,
limit_reached on links, cooldown refire suppression, notify channels + event
recording, skipped kinds, evaluateAll stats, events owner-scoped) — suite
**416/2779 green**, lint **188/0**. Live `scripts/_alerts_check.py`
**24/24**: UI, invalid kind refused, conditions/channels sanitised in DB,
**real SQLi-looking tenant hit (anonymous, XFF) → worker → alert fired once →
owner inbox notification + email queued → second attack within cooldown
suppressed → toggle/delete cascade**. smoke_http **164/164**, smoke_swarm
**53/53**.
**Live-script lesson:** URL-encode attack payloads (`%20`, `%3D`) —
http.client rejects raw spaces in the request line.

**Next:** P10d comments/notes (link_comments UI on the link page), then
P10e teams (teams/team_members/team_invites + accept flow + shared access),
then P11-P16.

## Block P10d — link comments (2026-10-17)

**Done:**
- `LinkService`: listComments (author join, parents-then-replies ordering via
  COALESCE(parent_id,id)), addComment (body required, 5000-char clamp, link
  must exist, replies flattened to ONE level — reply-to-reply attaches to the
  root), toggleCommentResolved, deleteComment (soft; author or link owner;
  audit rows `commented`/`comment_deleted`).
- Routes: POST /dashboard/links/{id}/comments, …/comments/{cid}/resolve,
  …/comments/{cid}/delete + LinksTrait handlers (ownedLink guard, flashes).
- `dashboard/links/show.php`: comments card — threaded list (indent + border
  for replies), author/time/resolved badge, reply links (?reply=ID#comments
  prefills parent_id + cancel), resolve/reopen toggle, soft-delete with
  confirm (owner-scoped page: the owner may remove anything — team roles
  refine this in P10e). linkShow passes comments + currentUserId.
- i18n en/ru 14 keys. (Notes were already done in P4 — owner_notes field.)

**Tests/proof:** LinkServiceTest +5 (validation/clamp/listing with author
join, threading+flatten order, resolve toggle, soft-delete permissions incl.
FK-safe second user, purge cascade) — suite **421/2806 green**, lint
**188/0**. Live `scripts/_comments_check.py` **15/15** (card, empty state,
add/refuse-empty, reply nesting + flatten, ?reply= prefill, resolve/
unresolve, soft delete hides the row, audit trail). smoke_http **164/164**,
smoke_swarm **53/53**.

**Next:** P10e teams — the last P10 sub-block (teams/team_members/team_invites
tables + account.team/invite/memberRole/acceptInvite stubs), then P11-P16.

## Block P10e — teams (2026-10-17) — P10 COMPLETE

**Done (commit `c851a7b`):**
- `src/Domain/Team/TeamService.php`: every user owns one auto-provisioned
  team (`ensureTeam`, slug from username with dedupe — 'BOSS' vs 'boss'
  collide on slug, not username); role ladder owner>admin>editor>viewer with
  hard guards (owner row immutable; admins are owner-only to create/change/
  remove; members may leave themselves; owner cannot leave); `roleOf` /
  `canManage` are the single source of truth for the P11 access wiring.
- Invites: raw 64-hex token shown ONCE (redirect ?invite_token=), sha256 hash
  at rest, 7-day TTL, single-use, pending re-issue (same row, fresh token —
  no pile-up), revoke (IS NULL guards — the equality builder cannot express
  them, same trap as markRead), email lowercased. Delivery: `team_invite`
  email template (migration 008, en+ru) through the outbox + inbox
  notification when the invitee already has an account. Acceptance:
  GET /invite/{token} (route existed as a stub passing the token AS A STRING —
  the implementation signature matches), requireAuth bounces anonymous users
  to login preserving `intended`; owner gets a team_joined notification.
- AccountController: team/invite/memberRole/memberRemove/acceptInvite stubs
  replaced + revokeInvite (+route). `account/team.php`: member table with
  inline role selects (autosubmit), owner badges, remove/leave with confirm,
  invite form (only the owner sees the admin option), pending-invite table
  with revoke, one-time link reveal alert, other-teams badges. i18n en/ru
  38 keys. Bootstrap factory.
- Scope note (recorded in §9): team RBAC enforcement on links/analytics
  controllers (shared access) is P11 — until then membership is
  organisational; the service API for it is ready.

**Tests/proof:** TeamServiceTest 8 tests (provisioning idempotency + slug
collision, full invite lifecycle incl. hash-at-rest/outbox/inbox-note/reissue/
single-use, revoke+expiry, role ladder guards, remove/leave rules, teamsOf,
slugify) — suite **429/2866 green**, lint **191/0**. Live
`scripts/_team_check.py` **17/17**: two real users — owner invites, invitee
registers, anonymous accept bounces to login, token accept creates the
editor membership, owner notified, token reuse refused, role change, owner
immutable, second invite revoked, member removed. smoke_http **164/164**,
smoke_swarm **53/53**. CHANGELOG Unreleased section now covers P8.6+P4+P7+P10.

**P10 status: ✅ COMPLETE** (notifications, webhooks, alerts, comments,
teams; notes shipped with P4 owner_notes; collections shipped with P4).
**Next per §9:** P11 admin remainder + team RBAC wiring, then P12 i18n batch,
P13 design pass, P14 load/security review, P15 beta loop, P16 Docker/CI.

## Block P11a — admin core (2026-10-17)

**Done (commit `1220497`):** 13 AdminController stubs replaced — index
(counters/queues/recent audit + run-worker & clear-cache buttons), users
(q/role/status filters, pagination, inline role select with autosubmit,
suspend↔reinstate with **live-session revocation**, guards: owner immutable,
no self-demote/self-suspend), subdomains/links/visits cross-tenant explorers
(read-only; links has a trash toggle, visits has hack/bot/status filters),
audit viewer (action LIKE filter), health (PHP/SQLite/journal/db size/mail
driver/telegram + queue depths), migrations status, jobs viewer + runJobs
(one full worker pass inline, audited), clearCache. Shared tab bar
`partials/admin-nav.php` (21 tabs, stubs honestly answer 501), sidebar
"Admin" entry for role∈{admin,owner}, settings page joined the nav.
**Bug class found:** bare `Db::` in AdminController (no import) → 500s;
PRAGMA cannot go through prepare() → pdo()->query. i18n +46 keys en/ru.

**Proof:** live `scripts/_admin_pages_check.py` **43 checks ×2 runs all
passed** (all pages render, filters work, suspend revokes sessions, guards
hold, actions audited, run-worker flashes summary, stubs → 501). Suite
**429/2866**, lint **201/0**, smoke **164/164 + 53/53**.

**Next:** P11b swarm admin (agents/sections/messages/tasks/proposals/
complaints), P11c security/flags/plans/geoip/ip-blocks, P11d team RBAC.

## Block P11b — swarm admin (2026-10-17)

**Done (commit `34a13be`):** the ten swarm stubs in AdminController replaced —
agents registry (q/status filters, per-agent counters via subqueries), agent
detail (profile table, metrics card incl. live tokens + open complaints
against, last 20 swarm_audit rows, suspend↔reinstate + verify toggles, all
audited), sections explorer (path/title/depth/visibility/lang + message
counts), messages moderation view (body excerpt, votes, deleted dimming,
q filter), tasks oversight (status chips, priority, assignee/creator),
proposals roadmap moderation (vote score = SUM(vote), inline status select →
POST /admin/proposals/{id}/status, audited, enum-validated), complaints queue
(open-first ordering, inline status + resolution → POST …/complaints/{id}/
status stamps handled_by/handled_at when leaving open; new route).
7 new templates; i18n +19 keys en/ru.
**Schema lessons:** agent_sections ownership column is `owner_agent_id` (not
created_by); feature_votes column is `vote` ±1 (not weight); agent_messages
requires `checksum`; `(string)$x ?? ''` precedence trap in templates.

**Proof:** live `scripts/_swarm_admin_check.py` **16/16** (seeded swarm
fixtures → every page renders its row, status filter, suspend/verify/
reinstate, proposal planned + bogus-status refused, complaint upheld with
resolution + handled_by, everything audited). `_admin_pages_check.py` updated
(agents is no longer a stub) — **43 checks green**. Suite **429/2866**, lint
**208/0**, smoke **164/164 + 53/53**.

**Next:** P11c security desk + ip-blocks + geoip + flags + plans (the last 9
admin stubs), then P11d team RBAC wiring.

## Block P11c — security desk + platform admin (2026-10-17)

**Done (commits `ac0cf72`, cleanup after):** the last 9 AdminController stubs —
- `/admin/security`: 12-tile counter grid (attacks today/total, blocked, bots,
  active IP blocks, rate-limit rows, abuse reports sent/failed today, spam
  intake open, complaints open, suspended agents/users) + recent flagged
  visits + recent blocks, cross-linked to the explorers.
- `/admin/ip-blocks`: CIDR manager — bare IP normalises to /32 or /128,
  kind enum (manual/abuse/scanner/bruteforce/spam), severity 1-5, optional
  expiry (datetime-local), upsert by UNIQUE(cidr), delete with confirm, all
  audited. Enforcement at the edge is documented as P14 work (the table is
  the source of truth; nothing reads it yet — stated in the UI hint).
- `/admin/geoip`: read-only status — driver, token presence, database file
  (exists/size/mtime), cache TTL, live TOR exit nodes, known VPN ranges,
  geocoded visit count.
- `/admin/flags`: feature_flags editor — one form, per-row upsert
  (enable/rollout %/allowlist/description) + a new-flag row; allowlist
  parsed from comma/space-separated input; key format guarded.
- `/admin/plans`: plan catalogue — create (unique code guard) + inline edit
  rows (price cents, currency upper-cased, period enum, limits JSON
  validated, features CSV→JSON, is_active, sort_order, subscriptions count),
  audited.
- **Dead code purge:** AdminController carried 79 unreachable
  `notImplemented` stubs (routes for those names point at Dashboard/Account
  controllers) + the notImplemented() helper — removed; 94 KB → 62 KB,
  35 live methods, zero stubs. i18n +37 keys en/ru.

**Proof:** live `scripts/_sec_admin_check.py` **18/18** (pages render, bad
CIDR refused, bare-IP→/32, block delete, flag create/update with allowlist
parsing, plan create/dup-refused/update with normalisation, audit trail);
`_admin_pages_check.py` extended — **all 11 former-stub pages now 200** —
green; `_swarm_admin_check.py` 16/16; suite **429/2866**; lint **213/0**;
smoke **164/164 + 53/53**.

**Next:** P11d — team RBAC wiring (TeamService::roleOf gating on links/
analytics, saved_views.is_shared), then P12 i18n batch (63 locales),
P13 design pass, P14 load/security (incl. ip_blocks enforcement), P15 beta,
P16 Docker/CI.

## Block P11d — team RBAC + shared views (2026-10-17) — P11 COMPLETE

**Done:**
- **Access model wired** (LinksTrait): `userSubdomain()` gained a team
  fallback — a member without their own subdomain works in the team owner's
  space (first membership with an active subdomain wins; multi-team switcher
  is a later UX item). `subdomainRole()` = 'owner' for own subdomain, else
  TeamService::roleOf in the owner's team. Gates: **editor+** for 14 write
  paths (store/update/delete/duplicate/restore/bulk/import/targeting rules &
  variants/comments), **admin+** for purge/purge-all/bulk-purge; viewers get
  the read-only dashboard (analytics needs no extra gate — it is read-only
  and scoped by subdomain id). `ownedLink()`/`ownedLinkIncludingTrash()`
  resolve through the team role. **No author shortcut** in canEditLink/
  canManageLink: a demoted member loses write access even to links they
  authored (found live: an editor could purge their own link — fixed).
- **Shared saved views**: SavedViewService.create/update learned is_shared,
  `listSharedForSubdomain()` (badged 'shared' => 1) and `findAccessible()`
  (own OR shared-with-this-subdomain — foreign ids refused); visits explorer
  merges own + shared chips ('· shared' suffix); views manager got a Team
  column with Share/Unshare buttons (set_shared submit); viewStore accepts
  is_shared. +1 unit test (7 assertions-worth of sharing matrix).
- Unified `errors.forbidden` flash via `forbidden()` helper (i18n key added
  in P10e already).

**Proof:** live `scripts/_rbac_check.py` **19/19** — two fresh users invited
by the demo owner (editor + viewer) through the real email-invite flow:
editor sees the team subdomain and creates links in it; viewer reads but
every write is refused (create/update), editor soft-deletes but CANNOT purge
(admin+), owner purges; owner saves + shares a view → editor sees the chip,
applies it (status filter carried), foreign view ids refused. Suite
**430/2878 green**, lint **213/0**, smoke **164/164 + 53/53**, views live
**17/17** (no regressions).
**Design note discovered live:** accepting an invite lands on /account/team,
which auto-provisions the member's OWN team too (by design) — membership
queries in tests/scripts must scope by team_id.

**P11 status: ✅ COMPLETE** (admin fully implemented + stub-free, team RBAC,
shared views). **Next per §9:** P12 i18n batch (63 locales pending
translation), P13 design pass, P14 load/security review (incl. ip_blocks
EDGE ENFORCEMENT — the table is managed but nothing reads it yet), P15 beta
loop, P16 Docker/CI/release.
---

## 2026-10-17 (evening) — P12 i18n: pipeline re-sync + uk 100% + be batch 1

**P12.0 — pipeline re-sync (critical fix).** Discovered that the P8.6-P11
strings (~560 keys) had been added directly to the GENERATED `en.json` /
`ru.json` while the build sources (`_base.en.json` 850 keys,
`_platform.ru.json` 850) lagged behind — any `build_i18n.php` run would have
silently destroyed the new keys in ALL locales. Fixed by regenerating both
sources from the live catalogs minus the 53 legacy keys (flatten of
`legacy/iya-ru-source/lang/{en,ru}.php`): `_base.en.json` = 1409 keys,
`_platform.ru.json` = 1409 (ru stays 100%). Rebuilt all 66 locales; verified
en/ru value-diff = 0; every catalog now carries the full 1462-key set
(1409 platform + 53 legacy). Added `tests/unit/Web/I18nSyncTest.php` —
drift guard (base↔en parity, ru coverage + parity, per-catalog keyset;
compact diff-assertions, not per-key). Documented the contract as
**REWORK-STATE §5.4** and corrected the stale Python-era §0 Runtime/Tests
rows. Commit: pipeline re-sync + guard.

**P12.1 — Ukrainian (uk) fully translated.** `_platform.uk.json` written in
5 python read-update-write batches (no JSON syntax risk, indent=1,
ensure_ascii=False): (1) about/api/auth/common/dashboard/errors/home/legacy/
nav/privacy/range/subdomain/tenant/time — 410 keys; (2) links/collections/
comments/targeting — 225; (3) visits/stats/views — 285; (4) admin/alerts/
settings — 236; (5) swarm/team/notify/notifications/webhooks — 253.
Total 1409/1409, exact key parity with base (0 missing / 0 extra).
Rebuild → coverage 100%. QA: placeholder regex diff `{…}` base-vs-uk = 0
mismatches; live `?lang=uk` — home ("Ваші посилання…"), login ("Увійти",
"З поверненням"), docs — Ukrainian rendered, `lang="uk"` attribute set, en
fallback intact. Commit `6c275f2`.

**P12.2 — Belarusian (be) batch 1.** Same group-1 set (410 keys) translated
and merged; one self-caught typo fixed (`common.live` → "Жывы эфір").
Rebuilt: be = 410/1409 (29%), rest falls back to en by design; placeholder
QA clean. Commit `b9b4228`. **Remaining: be batches 2-5 (999 keys)** — same
pattern, exact recipe in §9.

**Checkpoint:** 434 tests / 2949 assertions green · lint 214/0 · coverage:
en/ru/uk 100%, be 29%, de 2%, others 0% (all with full keyset).

**P12.2 completed — Belarusian (be) 100%.** Batches 2-5 landed in the same
session (links/collections/comments/targeting 225, visits/stats/views 285,
admin/alerts/settings 236, swarm/team/notify/notifications/webhooks 253) →
`_platform.be.json` = 1409/1409, exact parity (0 missing/extra), rebuild →
**full coverage: 4/66** (en/ru/uk/be), placeholder QA 0 mismatches.
**Discovery (ADR):** `?lang=be` rendered English despite the catalog being
complete — the runtime whitelist `SUPPORTED_LOCALES` (.env) listed only 42
of the 66 built locales and lacked `be`. Fixed: `be` added to `.env` and
`.env.example` (uk moved next to ru for grouping); live re-check green —
home "Вашы спасылкі", login "Увайсці"/"З вяртаннем", picker shows
"Беларуская", `lang="be"` attribute, uk/en unaffected. §5.4 extended with
the **activation gate** rule: after finishing a locale, add its code to
SUPPORTED_LOCALES in BOTH .env and .env.example. Next per §9: de (30 keys
exist) → es/fr/it/pt/pl/tr/zh/ja/ko → the rest; remember the gate.

## 2026-10-17 (late) — P12.3 i18n: German (de) 100%

**de complete: 1409/1409 platform keys.** Translated in 7 prefix batches
(common/nav/home/about/legacy 188 → auth/errors/privacy/tenant/subdomain/
time/range/api 185 → links/collections/comments 200 → visits/stats 262 →
views/dashboard 60 → admin/settings 189 → swarm/team 160 → alerts/notify/
notifications/webhooks/targeting 165) via python read-update-write heredocs
into `_platform.de.json`, then `php scripts/build_i18n.php --locale=de`.
Coverage 0 missing keys; placeholder QA 0 mismatches.

**Register correction (du vs Sie).** First pass used formal Sie; the test
`HttpSmokeTest::testLangParameterOverridesAcceptLanguage` failed expecting
"Deine Links". Root cause: the ESTABLISHED German voice for the new platform
is **informal du-form** — the pre-existing `_platform.de.json` keys shipped
"Deine Links, deine Statistiken", "Erstelle eine persönliche Subdomain…",
"Deine IP-Adresse" (git HEAD fefba19), while the LEGACY standalone IP service
(`legacy/iya-ru-source/lang/de.php`) uses formal Sie. Decision: keep the new
platform on du-form (matches tests + shipped marketing copy + modern German
SaaS norm); legacy.* strings rendered by the new system also follow du for
consistency. Scanned for formal markers (`\b(Sie|Ihre|Ihnen|…)\b` / `…en Sie`),
found 95 hits, converted 94 to du-form; 1 was a false positive
(`stats.funnels_need_tracker` — "Sie" = the funnels/"Trichter" pronoun, not
address) and was left untouched. Re-scanned: 0 real formal markers remain.

**Verification.** `php tests/run.php` = 434 tests / 2949 assertions / 0 failures;
`php bin/iya lint` = 214/0; `build_i18n.php --check` = 5/66 full coverage;
`docs/I18N-STATUS.md` de row = 1409/1409 (100%). Live `?lang=de` (Host: iya.ru):
home 200 `<title>Deine Links, deine Statistiken — iya.ru`, `lang="de"`, nav
"Anmelden"/"Über das Projekt"/"Agentenschwarm"; /about 200 "Über das Projekt";
/dashboard 302→login (unauth, correct). `de` was already whitelisted in
SUPPORTED_LOCALES (.env + .env.example) so no activation-gate change needed.
Coverage now **5/66** (en/ru/uk/be/de). Next per §9: es (empty), then
fr/it/pt/pl/tr/zh/ja/ko → rest; check each locale's tone convention first.

## 2026-10-17 (late) — P12.4 i18n: Spanish (es) 100%

**es complete: 1409/1409 platform keys.** Created `_platform.es.json` from
scratch (no prior platform file; only legacy `es.php`). Same 7-batch pattern
as de: common/nav/home/about/legacy (188) → auth/errors/privacy/tenant/
subdomain/time/range/api (185) → links/collections/comments (200) →
visits/stats (262) → views/dashboard (60) → admin/settings (189) →
swarm/team (160) → alerts/notify/notifications/webhooks/targeting (165),
written via python read-update-write heredocs (sorted, `ensure_ascii=False,
indent=1`, trailing newline), then `build_i18n.php --locale=es`. Coverage
0 missing; placeholder QA 0 mismatches.

**Register = informal tú** (matches legacy `es.php` "Tu Dirección IP" and the
modern Spanish SaaS norm). home.hero_title = "Tus enlaces, tus estadísticas";
cta "Crea tu subdominio"; imperatives in tú ("Introduce", "Selecciona",
"Guarda", "Crea"). Tone scan for usted-form markers found only false positives
("Debes"/"debe estar" = tú/3rd-person, not usted) — 0 real formal forms.
Included a `_meta` header (locale/name/native/direction/note) mirroring de;
the build `unset`s source `_meta` and emits its own, so it is decorative.

**Verification.** `php tests/run.php` = 434/2949/0; `php bin/iya lint` = 214/0;
`build_i18n.php --check` = 6/66 full coverage. `es` already whitelisted in
SUPPORTED_LOCALES (.env + .env.example) — no activation-gate change needed.
Live `?lang=es` (Host: iya.ru): home 200 `<title>Tus enlaces, tus estadísticas
— iya.ru`, `lang="es"`, nav "Iniciar sesión"/"Sobre el proyecto"/"Enjambre de
agentes"; /about 200 "Sobre el proyecto"; /dashboard 302→login (unauth,
correct). Coverage now **6/66** (en/ru/uk/be/de/es). Next per §9: fr (check
`fr.php` for tu vs vous), then it/pt/pl/tr/zh/ja/ko → rest.

---

## 2026-10-17 (post-crash) — sandbox reset recovery + P12 phase archive

**Что случилось.** Сессия прервалась сбоем песочницы. Диагноз при входе:
`php: command not found`, `.git` в `"$ARENA_WORKSPACE"/iya-platform` отсутствует
(снапшот его не сохранил), `/workspace/*` пуст. Выжили только `"$ARENA_WORKSPACE"`
(проект + `backup/versions/`, последний `..._86ed213.tar.gz`) и `/mnt/oss/*`.
Источники кода не потеряны — потерян только runtime и git-история.

**Восстановление (по ENV-пункту 5).**
1. `apt-get update && apt-get install -y php-cli php-sqlite3 php-mbstring
   php-xml php-curl php-intl php-zip` → PHP 8.2.34 (cli), модули curl/intl/
   mbstring/pdo_sqlite/sqlite3/zip/openssl/sodium на месте. Первая попытка без
   `apt-get update` не нашла пакеты — обновление списка обязательно.
2. Здоровье: `php tests/run.php` → **434 tests / 2949 assertions / 0 failures**
   (22.8 s), включая `I18nSyncTest` (4/4). `php bin/iya lint` → **214 files /
   0 failures**. `php scripts/build_i18n.php --check` → 66 каталогов, 1409
   ключей, полное покрытие **6/66** (en/ru/uk/be/de/es) — ровно как до сбоя.
3. `git init` + baseline-коммит `ce0e15f` (558 файлов; `.gitignore` исключает
   var/, data/*.sqlite3*, .env). Старые хеши коммитов из WORKLOG остаются
   ссылками на `backup/versions/*` — историю не переписываем.

**Новое по требованию пользователя: архив `P12` в корне Workspace.**
Добавлен пункт 6 ритуала чекпоинта (§9): помимо `sync.sh` на каждом чекпоинте
формируется фазовый архив с фиксированным именем.
- `scripts/pack_p12.sh` — health-гейт (tests+lint+i18n --check+git rev),
  автообновление статус-блока в манифесте, упаковка `tar -czf`, атомарный
  `mv` из `.tmp`, вывод размера и числа записей. Поддерживает `--name=` и
  `--no-checks`. Исключает `var/*` (566 МБ рантайма), `data/*.sqlite3*`,
  `data/{queue,logs,screenshots}/*`, `__pycache__`, `node_modules`, `*.tmp`.
  **Включает `.git`** (7.5 МБ) — архив самодостаточен для восстановления
  истории, чего `backup/versions/*` не гарантируют после сбоя.
- `"$ARENA_WORKSPACE"/P12-MANIFEST.md` — что внутри, новое правило, состояние,
  таблица сделанного за цикл (P8.6 → P12.4 + восстановление), известные
  пробелы, пошаговая инструкция восстановления. Копируется внутрь архива.
- Результат: **`"$ARENA_WORKSPACE"/P12.tar.gz`, 25 МБ**, манифест + `iya-platform/`
  (1745 файлов, 72 i18n-каталога), git-лог внутри распакованной копии читается.
- Различие слоёв защиты зафиксировано: `P12.tar.gz` **перезаписывается** на
  каждом чекпоинте (свежайшее «всё сделанное»), `backup/versions/*` — никогда.

**Верификация архива.** `tar -xzf` во временный каталог → присутствуют
`REWORK-STATE.md`, `WORKLOG.md`, `web/i18n/_base.en.json`,
`_platform.es.json`, `scripts/pack_p12.sh`; `git log` в распакованном репо
даёт `ce0e15f`. Распакованная копия удалена.

**Документация.** §0 (строки Last checkpoint / Lost context? — добавлен
приоритет источников восстановления), §9 (правила 6 и 7: архив P12 и
последовательность действий после сброса). Манифест архива — отдельный файл в
корне Workspace, чтобы быть видимым без распаковки.

**Тесты:** 434/2949/0 · lint 214/0 · i18n 6/66 · **коммит `ce0e15f`**
(baseline) + следующий коммит с `pack_p12.sh`/доками.

**Следующий шаг (§9):** P12.5 — французский (`fr`): сначала тон по
`legacy/iya-ru-source/lang/fr.php` (tu vs vous), `_platform.fr.json`
отсутствует; 7 батчей как для es/de, затем build + placeholder-QA (0
расхождений) + live `?lang=fr` + чекпоинт **с пересборкой `P12.tar.gz`**.

## 2026-10-17 (post-crash, evening) — durable duplication rules (ADR-014) + backup audit

**Контекст.** Пользователь перед стартом P12.5 (fr) потребовал: (1) проверить
`backup/versions` на архив с частью сделанного, (2) дублировать файлы туда, где
они не пропадут, (3) делать архив в backup каждые 10 языков.

**Аудит backup/versions.** Просканированы ВСЕ 21 архив: ни в одном нет
`_platform.{fr,it,pt,pl,tr,zh,ja,ko}.json` — частичной французской работы на
диске не существовало (предыдущая сессия успела только.dump ключей batch-1 в
контекст, файл не записывался).Актуальный архив
`iya-platform_20261017T212743Z_19f7d03.tar.gz` содержит всё сделанное:
5 платформ (ru/uk/be/de/es), `pack_p12.sh`, доки. `/workspace/*` — зеркала,
восстановлены sync.sh в 21:27, нового не содержат. Потерь нет.

**Новое: `scripts/backup_i18n.sh`** (executable):
- `bash scripts/backup_i18n.sh <locale>` — сразу после коммита языка копирует
  `_base.en.json` + `_platform.<locale>.json` + сгенерированный `<locale>.json`
  в `<workspace>/backup/i18n/` (переживает сброс) и `/mnt/oss/iya-i18n/`
  (best effort, `/mnt/oss` проверен — WRITABLE);
- автоматически при каждом 10-м языке полного покрытия (full coverage % 10 == 0):
  вызывает `sync.sh` (версионный tarball → `backup/versions/`, не перезаписывается)
  и зеркалит свежайший tarball + `P12.tar.gz` в `/mnt/oss/iya-platform-backups/`;
  идемпотентно через маркеры `backup/i18n/.milestone-N`;
- `--milestone` (принудительно) и `--bootstrap` (разовое зеркалирование).

**Документация.** REWORK-STATE.md: ADR-014 (§1), правила 8–9 чекпоинт-ритуала
(§9), упоминание `backup_i18n.sh fr` в NEXT ACTION блока P12.

**Тесты:** 434/2949/0 · lint 214/0 · i18n --check 6/66 (без изменений — переводов
не добавлялось) · коммит см. git log · `pack_p12.sh` пересобран ·
`backup_i18n.sh --bootstrap` выполнен (en-файлы продублированы, tarball+P12
зеркалированы в /mnt/oss).

**Следующий шаг (§9):** P12.5 — French (vous-регистр по legacy `fr.php`),
7 батчей, после коммита — `bash scripts/backup_i18n.sh fr`.

## 2026-10-17 (evening) — P12.5 i18n: French (fr) 100%

**fr complete: 1409/1409 platform keys.** `_platform.fr.json` создан с нуля
(до этого существовал только legacy `fr.php`). Тот же 7-батчевый паттерн, что
для de/es: common/nav/home/about/legacy (188) → auth/errors/privacy/tenant/
subdomain/time/range/api (185) → links/collections/comments (200) →
visits/stats (262) → views/dashboard (60) → admin/settings (189) →
swarm/team (160) → alerts/notify/notifications/webhooks/targeting (165).
Запись через python read-update-write heredocs (sorted, `ensure_ascii=False,
indent=1`, trailing newline) + `_meta`-шапка как у de/es. После каждого батча
файл дублировался в `backup/i18n/` (правило 8) — частичный прогресс пережил
бы любой сброс.

**Регистр = формальный vous** (по legacy `fr.php` «Votre Adresse IP»):
home.hero_title «Vos liens, vos statistiques», cta «Créez votre sous-domaine»,
императивы на -ez («Créez», «Saisissez», «Choisissez»). Французская типографика:
пробел перед : ; ! ?, кавычки « », неразрывные тире — сохранены; плейсхолдеры
{count}/{name}/{days}/{percent} и т.д. — 0 расхождений (regex-QA base-vs-fr).

**Верификация.** `php scripts/build_i18n.php --locale=fr` → каталог собран;
`--check` → **full coverage: 7/66** (en/ru/uk/be/de/es/fr), 0 пропусков.
Тесты 434/2949/0 · lint 214/0. Live `?lang=fr` (dev-сервер `php bin/iya serve`
+ `?_host=iya.ru`): home 200 `<title>Vos liens, vos statistiques — iya.ru`,
`lang="fr"`, nav «Se connecter»/«À propos»/«Essaim»; /about 200 «À propos du
projet»; /auth/login 200 «Mot de passe oublié ?»/«Pas encore de compte ?»;
/auth/register 200 «Créer un compte»; /dashboard 302→/auth/login (unauth,
корректно). Регрессий нет: de «Deine Links…», es «Tus enlaces…» на месте.
`fr` уже в SUPPORTED_LOCALES (.env + .env.example) — гейт не менялся.

**Заметка для следующих языков:** маршрут логина — `/auth/login` (не `/login`),
dev-пробы делать с `?_host=iya.ru`; в heredoc'ах пути только относительные
(хардкод `"$ARENA_WORKSPACE"` внутри содержимого не перемаппится — поймано на stats-дампе).

**Следующий шаг (§9):** P12.6 — Italian (it), регистр **tu** по legacy
`it.php` («Il tuo indirizzo IP»); после коммита — `backup_i18n.sh it`;
на 10-м языке полного покрытия (после pl) автоматически сработает milestone-архив.

## 2026-10-17 (evening) — P12.6 i18n: Italian (it) 100%

**it complete: 1409/1409 platform keys.** `_platform.it.json` создан с нуля.
Тот же 7-батчевый паттерн (188/185/200/262/60/189/165/165 — суммы групп как
для fr/de/es), python read-update-write heredocs, дублирование частичного
файла в `backup/i18n/` после каждого батча (правило 8).

**Регистр = неформальный tu** (по legacy `it.php` «Il tuo indirizzo IP»):
home.hero_title «I tuoi link, le tue statistiche», cta «Crea il tuo
sottodominio», императивы tu («Crea», «Inserisci», «Scegli», «Conserva»).
Типографика: «» для цитат-примеров, — сохранено; placeholder QA (regex
{…} base-vs-it) = **0 расхождений**.

**Верификация.** `build_i18n.php --locale=it` → собран; `--check` →
**full coverage: 8/66** (en/ru/uk/be/de/es/fr/it). Тесты 434/2949/0 ·
lint 214/0. Live `?lang=it` (`php bin/iya serve` + `?_host=iya.ru`):
home 200 `<title>I tuoi link, le tue statistiche — iya.ru`, `lang="it"`,
nav «Accedi»/«Sciame di agenti»; /about 200 «Informazioni sul progetto»;
/auth/login 200 «Password dimenticata?»/«Non hai ancora un account?»;
/dashboard 302→/auth/login (первый прогон дал разовый 404 — перепроверено
на свежем сервере: 302 для it/fr/en/de, инцидент не воспроизвёлся).
Регрессий нет: fr «Vos liens…» на месте. `it` уже в SUPPORTED_LOCALES.

**Следующий шаг (§9):** P12.7 — Portuguese (pt), регистр **você/seu** по
legacy `pt.php` («Seu Endereço IP»); после коммита — `backup_i18n.sh pt`;
после pl (10-й язык) автоматически сработает milestone-архив (правило 9).

## 2026-10-17 (evening) — P12.7 i18n: Portuguese (pt) 100%

**pt complete: 1409/1409 platform keys.** `_platform.pt.json` создан с нуля,
тот же 7-батчевый паттерн, частичный файл дублировался в `backup/i18n/`
после каждого батча (правило 8).

**Регистр = você/seu** (по legacy `pt.php` «Seu Endereço IP»): home.hero_title
«Seus links, suas estatísticas», cta «Crie seu subdomínio», императивы você
(«Crie», «Digite», «Escolha», «Guarde»). Placeholder QA (regex {…}
base-vs-pt) = **0 расхождений**.

**Верификация.** `build_i18n.php --locale=pt` → собран; `--check` →
**full coverage: 9/66** (en/ru/uk/be/de/es/fr/it/pt). Тесты 434/2949/0 ·
lint 214/0. Live `?lang=pt` (`php bin/iya serve` + `?_host=iya.ru`):
home 200 `<title>Seus links, suas estatísticas — iya.ru`, `lang="pt"`,
nav «Entrar»/«Enxame de agentes»; /about 200 «Sobre o projeto»;
/auth/login 200 «Esqueceu sua senha?»/«Ainda não tem conta?»;
/dashboard 302→/auth/login. Регрессий нет: it «I tuoi link…» на месте.
`pt` уже в SUPPORTED_LOCALES.

**Следующий шаг (§9):** P12.8 — Polish (pl), тон по legacy `pl.php`
(вероятно неформальный ty: «Twój adres IP»). **pl — 10-й язык полного
покрытия**: после его коммита `backup_i18n.sh pl` автоматически сработает
как milestone (правило 9): sync.sh tarball → backup/versions + зеркало
tarball+P12.tar.gz → /mnt/oss/iya-platform-backups/.

## 2026-10-17 (evening) — P12.8 i18n: Polish (pl) 100% + MILESTONE-10

**pl complete: 1409/1409 platform keys.** `_platform.pl.json` создан с нуля,
7 батчей, частичный файл дублировался в `backup/i18n/` после каждого батча.

**Регистр = неформальный ty** (по legacy `pl.php` «Twój adres IP»):
home.hero_title «Twoje linki, Twoje statystyki», cta «Utwórz swoją poddomenę»,
императивы ty («Utwórz», «Wprowadź», «Wybierz», «Zachowaj»). Placeholder QA
(regex {…} base-vs-pl) = **0 расхождений**.

**Верификация.** `build_i18n.php --locale=pl` → собран; `--check` →
**full coverage: 10/66** (en/ru/uk/be/de/es/fr/it/pt/pl). Тесты 434/2949/0 ·
lint 214/0. Live `?lang=pl`: home 200 `<title>Twoje linki, Twoje statystyki —
iya.ru`, `lang="pl"`, nav «Zaloguj się»/«Rój agentów»; /about 200
«O projekcie»; /auth/login 200 «Nie pamiętasz hasła?»/«Nie masz jeszcze
konta?»; /dashboard 302→/auth/login. Регрессий нет: pt «Seus links…» на месте.

**MILESTONE-10 (правило 9, первый авто-запуск).** `backup_i18n.sh pl` →
coverage 10 % 10 == 0 → вызван `sync.sh` (версионный tarball →
`backup/versions/`, не перезаписывается), свежайший tarball + `P12.tar.gz`
зеркалированы в `/mnt/oss/iya-platform-backups/`, маркер
`backup/i18n/.milestone-10` записан (идемпотентность). Следующий milestone —
на 20-м языке полного покрытия.

**Промежуточный итог P12:** 10/66 языков по 1409 ключей = 14 090 переводов
в этом цикле (fr/it/pt/pl = 5636 сегодня вечером).

**Следующий шаг (§9):** P12.9 — Turkish (tr), тон по legacy `tr.php`;
после коммита — `backup_i18n.sh tr`.

## 2026-10-17 (evening) — P12.9 i18n: Turkish (tr) 100%

**tr complete: 1409/1409 platform keys.** `_platform.tr.json` создан с нуля,
7 батчей, частичный файл дублировался в `backup/i18n/` после каждого батча.

**Регистр = формальный siz** (по legacy `tr.php` «IP Adresiniz»):
home.hero_title «Linkleriniz, istatistikleriniz», cta «Alt alan adınızı
oluşturun»; кнопки — инфинитив (стандарт турецкого UI: «Kaydet», «Sil»,
«Uygula»), обращения к пользователю — на -siniz/-nız («Emin misiniz?»,
«saklayın»). Placeholder QA (regex {…} base-vs-tr) = **0 расхождений**.

**Верификация.** `build_i18n.php --locale=tr` → собран; `--check` →
**full coverage: 11/66** (en/ru/uk/be/de/es/fr/it/pt/pl/tr). Тесты
434/2949/0 · lint 214/0. Live `?lang=tr`: home 200
`<title>Linkleriniz, istatistikleriniz — iya.ru`, `lang="tr"`, nav «Giriş
yap»/«Ajan sürüsü»; /about 200 «Proje hakkında»; /auth/login 200
«Parolanızı mı unuttunuz?»/«Henüz hesabınız yok mu?»; /dashboard
302→/auth/login. Регрессий нет: pl «Twoje linki…» на месте. `tr` уже в
SUPPORTED_LOCALES.

**Следующий шаг (§9):** P12.10 — Chinese (zh): проверить тон legacy
`zh.php` (您 vs 你); в zh нет форм множественного числа — фразы с {count}
упрощаются. После коммита — `backup_i18n.sh zh`; следующий milestone — на
20-м языке.

## 2026-10-17 (evening) — P12.10 i18n: Chinese (zh) 100%

**zh complete: 1409/1409 platform keys.** `_platform.zh.json` создан с нуля,
7 батчей, дублирование частичного файла в `backup/i18n/` после каждого батча.

**Регистр = вежливый 您** (по legacy `zh.php` «您的IP地址»): home.hero_title
«您的链接，您的统计», cta «创建您的子域名». В zh нет форм мн. числа —
фразы с {count} упрощены («{count} 天前»). CJK-типографика: полноширинная
пунктуация （）、。「」—— сохранена; placeholder QA (regex {…} base-vs-zh) =
**0 расхождений**.

**Верификация.** `build_i18n.php --locale=zh` → собран; `--check` →
**full coverage: 12/66** (en/ru/uk/be/de/es/fr/it/pt/pl/tr/zh). Тесты
434/2949/0 · lint 214/0. Live `?lang=zh`: home 200
`<title>您的链接，您的统计 — iya.ru`, `lang="zh"`, nav «登录»/«智能体集群»;
/about 200 «关于项目»; /auth/login 200 «忘记密码？»/«还没有账户？»;
/dashboard 302→/auth/login. Регрессий нет: tr «Linkleriniz…» на месте.
`zh` уже в SUPPORTED_LOCALES.

**Итог сессии 2026-10-17 (вечер):** +6 языков (fr/it/pt/pl/tr/zh) =
8454 перевода, покрытие 6/66 → 12/66; внедрена durable-дупликация (ADR-014,
правила 8–9, `scripts/backup_i18n.sh`); MILESTONE-10 заархивирован
(backup/versions + /mnt/oss/iya-platform-backups + маркер).

**Следующий шаг (§9):** P12.11 — Japanese (ja): тон по legacy `ja.php`
(вероятно です/ます); после коммита — `backup_i18n.sh ja`; milestone — на 20-м.

## 2026-10-18 — Восстановление после сбоя песочницы №2 (агент-преемник)

**Полный сброс sandbox:** "$ARENA_WORKSPACE" пуст, PHP/git отсутствуют, .git не пережил.
Восстановление из снапшота `/mnt/oss/6f12661b-5674-4e43-b543-93e862d0bba1.tar.gz`
(18.10 00:13, `./workspace/` = живое состояние на момент упаковки P12 23:22Z,
коммит `a3b7193`). Альтернативные источники были: `/mnt/oss/iya-platform-backups/P12.tar.gz`
(26 МБ, 22:49 — старее) и versioned `iya-platform_20261017T232221Z_a3b7193.tar.gz`.

**Действия:** apt: php-cli+sqlite3+mbstring+xml+curl+intl+zip, git; распаковка
`./workspace/` из снапшота; `var/test` (559 МБ мусора) исключён; перенос в
"$ARENA_WORKSPACE"; **все 20 versioned-тарболлов зеркализированы в
`/mnt/oss/iya-platform-backups/`**, в `"$ARENA_WORKSPACE"/backup/versions/` оставлены
4 свежайших (лимит снапшота Workspace ~128 МБ — см. ADR-015); `git init` +
baseline `bb5a22a`.

**Здоровье:** тесты **434/2949/0** (21.9 s) · lint **214/0** · i18n `--check`
**full coverage: 12/66** — всё сходится с P12-MANIFEST §7.

**Следующий шаг (§9):** P12.11 — Japanese (ja), тон = вежливый です/ます
(legacy `ja.php`: «あなたのIPアドレス»). Далее ko.

## 2026-10-18 — P12.11 i18n: Japanese (ja) 100%

**ja complete: 1409/1409 platform keys.** `_platform.ja.json` создан с нуля,
7 батчей (188+185+200+322+189+160+165), частичный файл дублировался в
`backup/i18n/_platform.ja.json.partial` после каждого батча; промежуточный
WIP-коммит `9c8ac13` после батчей 1–4.

**Регистр = вежливый です/ます** (по legacy `ja.php` «あなたのIPアドレス»):
home.hero_title «あなたのリンク、あなたの統計», cta «サブドメインを作成».
Кнопки — существительные/сурu-основы (保存, キャンセル, 削除). Множественного
числа нет — фразы с {count} упрощены («{count}日前»). Японская типографика:
「」 для кавычек, ／ полноширинный слэш, … сохранено.Placeholder QA (regex
{…} base-vs-ja) = **0 расхождений**. GOTCHA: `notify.test_send` чуть не
потерялся из-за `| head -95` (строка `N=` съедает одну строку дампа) —
добавлен финальный QA «missing keys» перед build (зафиксировано в §9).

**Верификация.** `build_i18n.php --locale=ja` → собран; `--check` →
**full coverage: 13/66** (en/ru/uk/be/de/es/fr/it/pt/pl/tr/zh/ja). Тесты
434/2949/0 · lint 214/0. Live `?lang=ja`: home 200
`<title>あなたのリンク、あなたの統計 — iya.ru</title>`, `lang="ja"`, nav
«ログイン»/«エージェントスウォーム»; /about 200 «プロジェクトについて»;
/auth/login 200 «パスワードをお忘れですか？»/«アカウントをお持ちでない方»;
/dashboard 302→/auth/login. Регрессий нет: zh «您的链接…» на месте.
`ja` уже в SUPPORTED_LOCALES. Коммит `9e3e29d`, `backup_i18n.sh ja`
(backup/i18n + /mnt/oss/iya-i18n).

**Следующий шаг (§9):** P12.12 — Korean (ko): тон по legacy `ko.php`
(вероятно 습니다/합니다체); после коммита — `backup_i18n.sh ko`; milestone —
на 20-м языке.

## 2026-10-18 — P12.12 i18n: Korean (ko) 100%

**ko complete: 1409/1409 platform keys.** `_platform.ko.json` создан с нуля,
7 батчей (тот же план 188+185+200+322+189+160+165), частичный файл
дублировался в `backup/i18n/_platform.ko.json.partial` после каждого батча;
WIP-коммит `5ffbe6b` после батчей 1–3.

**Регистр = 격식체 합니다/입니다** (по legacy `ko.php` «당신의 IP 주소»):
home.hero_title «당신의 링크, 당신의 통계», cta «서브도메인 만들기».
Кнопки — существительные (저장, 취소, 삭제). Грамматического мн. числа нет —
фразы с {count} упрощены («{count}일 전»). {count}-фразы с счётными словами
через «{count}개». Placeholder QA = **0 расхождений**; missing-keys QA = 0
(урок ja учтён: `notify.test_send` включён сразу).

**Верификация.** `build_i18n.php --locale=ko` → собран; `--check` →
**full coverage: 14/66** (…/ja/ko). Тесты 434/2949/0 · lint 214/0.
Live `?lang=ko`: home 200 `<title>당신의 링크, 당신의 통계 — iya.ru</title>`,
`lang="ko"`, nav «로그인»/«에이전트 스웜»; /about 200 «프로젝트 소개»;
/auth/login 200 «비밀번호를 잊으셨습니까?»/«아직 계정이 없으신가요?»);
/dashboard 302→/auth/login. Регрессий нет: ja «あなたのリンク…» на месте.
`ko` уже в SUPPORTED_LOCALES. Коммит `93f5465`, `backup_i18n.sh ko`.

**Следующий шаг (§9):** P12.13 — Dutch (nl): тон по legacy `nl.php`
(вероятно неформальный je/jij); после коммита — `backup_i18n.sh nl`;
milestone — на 20-м языке.

## 2026-10-18 — P12.13 i18n: Dutch (nl) 100%

**nl complete: 1409/1409 platform keys.** `_platform.nl.json` создан с нуля,
7 батчей; частичный файл дублировался в `backup/i18n/_platform.nl.json.partial`;
WIP-коммит `48cbebf` после батчей 1–2.

**Регистр = формальный u** (по legacy `nl.php` «Uw IP-adres», а не je/jij как
предполагал §9): home.hero_title «Uw links, uw statistieken», cta «Uw subdomein
aanmaken». Множественное число есть — {count}-фразы сохранены («{count} dagen
geleden», «{count} link(s)»). Нидерландская типографика: «» для цитат сохранена,
— сохранено. Placeholder QA = **0 расхождений**; missing-keys QA = 0
(финальная проверка b.keys()-platform.keys() перед build).

**Верификация.** `build_i18n.php --locale=nl` → собран; `--check` →
**full coverage: 15/66** (…/ja/ko/nl). Тесты 434/2949/0 · lint 214/0.
Live `?lang=nl`: home 200 `<title>Uw links, uw statistieken — iya.ru</title>`,
`lang="nl"`, nav «Inloggen»/«Agentzwerm»; /about 200 «Over het project»;
/auth/login 200 «Wachtwoord vergeten?»/«Nog geen account?»; /dashboard
302→/auth/login. Регрессий нет: ko «당신의 링크…», ja «あなたのリンク…» на месте.
`nl` уже в SUPPORTED_LOCALES. `backup_i18n.sh nl` (backup/i18n +
/mnt/oss/iya-i18n).

**Следующий шаг (§9):** P12.14 — Swedish (sv): тон по legacy `sv.php`
(вероятно неформальный du); после коммита — `backup_i18n.sh sv`; milestone —
на 20-м языке (после sv+no+da+fi+cs — whichever crosses).

## 2026-10-18 — P12.14 i18n: Swedish (sv) 100%

**sv complete: 1409/1409 platform keys.** `_platform.sv.json` создан с нуля,
7 батчей; частичный файл дублировался в `backup/i18n/_platform.sv.json.partial`;
WIP-коммит `da54eb8` после батчей 1–2.

**Регистр = неформальный du** (по legacy `sv.php` «Din IP-adress»):
home.hero_title «Dina länkar, din statistik», cta «Skapa din subdomän».
Шведская типографика: «» для кавычек сохранена; {count}-фразы со шведскими
сокращениями («{count} d sedan», «{count} länk(ar)»). Placeholder QA = 0;
missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=sv` → собран; `--check` →
**full coverage: 16/66** (…/nl/sv). Тесты 434/2949/0 · lint 214/0.
Live `?lang=sv`: home 200 `<title>Dina länkar, din statistik — iya.ru</title>`,
`lang="sv"`, nav «Logga in»/«Agentsvärm»; /about 200 «Om projektet»;
/auth/login 200 «Glömt lösenordet?»/«Inget konto än»; /dashboard 302→login.
Регрессий нет: nl «Uw links…» на месте. `sv` уже в SUPPORTED_LOCALES.
`backup_i18n.sh sv` (backup/i18n + /mnt/oss/iya-i18n).

**Итог сессии 2026-10-18:** восстановление после сбоя №2 + 4 языка
(ja/ko/nl/sv) = 5636 переводов, покрытие 12/66 → 16/66.

**Следующий шаг (§9):** P12.15 — Norwegian (no): тон по legacy `no.php`
(вероятно du, букмол); далее da, fi; milestone-20 сработает автоматически
на 20-м языке (no/da/fi/cs).

## 2026-10-18 — P12.15 i18n: Norwegian (no) WIP 573/1409 (чекпоинт прерывания)

**no in progress: 573/1409 keys** (батчи 1, 2, 3a, 3b: common/nav/home/about/
legacy · auth/errors/privacy/tenant/subdomain/time/range/api · links/
collections/comments). Регистр = неформальный du, букмол (legacy `no.php`
«Din IP-adresse»). Частичный файл: `web/i18n/_platform.no.json` +
`backup/i18n/_platform.no.json.partial`. WIP-коммиты: `bc4e4a7` (188),
`d8866fd` (483), + этот (573).

**Точка прерывания:** следующий батч — 4 (stats 112 → visits 150 →
views+dashboard 60), затем 5 (admin/settings), 6 (swarm/team),
7 (alerts/notify/notifications/webhooks/targeting). После 1409: build
`--locale=no`, placeholder QA + missing-keys QA, тесты, live `?lang=no`,
коммит, `backup_i18n.sh no`. Тесты на момент WIP: 434/2949/0 (прогонялись
на sv-чекпоинте; partial-файл тесты не ломает — I18nSyncTest проверяет
сгенерированные каталоги, а `no.json` ещё не пересобран).

## 2026-10-18 — P12.15 i18n: Norwegian (no) 100%

**no complete: 1409/1409 platform keys.** `_platform.no.json` создан с нуля,
7 батчей; частичный файл дублировался в `backup/i18n/` после каждого батча;
WIP-коммиты: `bc4e4a7` (188), `d8866fd` (483), `7eee843` (573 + точка
прерывания в §9), `d7630f2` (895).

**Регистр = неформальный du, букмол** (по legacy `no.php` «Din IP-adresse»):
home.hero_title «Dine lenker, din statistikk», cta «Opprett ditt subdomene».
«» для кавычек сохранены; {count}-сокращения норвежские («{count} d siden»,
«{count} lenke(r)»). Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=no` → собран; `--check` →
**full coverage: 17/66** (…/sv/no). Тесты 434/2949/0 · lint 214/0.
Live `?lang=no`: home 200 `<title>Dine lenker, din statistikk — iya.ru</title>`,
`lang="no"`, nav «Logg inn»/«Agentsverm»; /about 200 «Om prosjektet»;
/auth/login 200 «Glemt passordet?»/«Ingen konto ennå»; /dashboard
302→/auth/login. Регрессий нет: sv «Dina länkar…» на месте.
`no` уже в SUPPORTED_LOCALES. `backup_i18n.sh no`.

**Итог сессии 2026-10-18 (продолжение):** +5 языков (ja/ko/nl/sv/no) =
7045 переводов, покрытие 12/66 → 17/66. До milestone-20 осталось 3 языка
(da/fi/cs — скрипт backup_i18n.sh сам запустит sync + зеркала).

**Следующий шаг (§9):** P12.16 — Danish (da): тон = неформальный du
(legacy `da.php` «Din IP-adresse» уже проверен); после коммита —
`backup_i18n.sh da`; далее fi (sinä/притяжательные суффиксы), cs, sk…

## 2026-10-18 — P12.16 i18n: Danish (da) 100%

**da complete: 1409/1409 platform keys.** `_platform.da.json` создан с нуля,
7 батчей; частичный файл — `backup/i18n/_platform.da.json.partial` после
каждого батча; WIP-коммиты `fcf72d6` (483), `2126985` (835).

**Регистр = неформальный du** (по legacy `da.php` «Din IP-adresse»):
home.hero_title «Dine links, din statistik», cta «Opret dit subdomæne».
Датские æ/ø/å сохранены; «» для цитат; {count}-фразы («{count} link(s)»,
«{count} d siden»). Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=da` → собран; `--check` →
**full coverage: 18/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=da`:
home 200 `<title>Dine links, din statistik — iya.ru</title>`, `lang="da"`,
nav «Log ind»/«Agentsværm»; /about 200 «Om projektet»; /auth/login 200
«Glemt din adgangskode?»/«Ingen konto endnu»; /dashboard 302→/auth/login.
Регрессий нет: no «Dine lenker…» на месте. `da` уже в SUPPORTED_LOCALES.
`backup_i18n.sh da`.

**Следующий шаг (§9):** P12.17 — Finnish (fi): тон по legacy `fi.php`
(«IP-osoitteesi» → неформальный, притяжательные суффиксы; после числительных
партивив единственного числа); после коммита — `backup_i18n.sh fi`;
milestone-20 сработает через 2 языка.

## 2026-10-18 — P12.17 i18n: Finnish (fi) WIP 286/1409 (чекпоинт прерывания)

**fi in progress: 286/1409 keys** (батчи 1, 2a: common/nav/home/about/legacy ·
api/auth/errors). Регистр = неформальный, притяжательные суффиксы
(«Linkkisi, tilastosi», «IP-osoitteesi» по legacy `fi.php`); после
числительных — партитив ед. числа («{count} päivää sitten»). Частичный файл:
`web/i18n/_platform.fi.json` + `backup/i18n/_platform.fi.json.partial`.
WIP-коммиты: `265ade7` (188), + этот (286).

**Точка прерывания:** следующий батч — 2b (privacy/range/subdomain/tenant/
time, 87), далее 3 (links/collections/comments, 200), 4 (visits/stats/views/
dashboard, 322), 5 (admin/settings, 189), 6 (swarm/team, 160), 7 (alerts/
notify/notifications/webhooks/targeting, 165). После 1409: build
`--locale=fi`, placeholder QA + missing-keys QA (оба 0), тесты, live
`?lang=fi`, коммит, `backup_i18n.sh fi`. **Milestone-20 сработает автоматически
на 20-м языке** (после fi + cs) — backup_i18n.sh сам запустит sync.sh и
зеркала /mnt/oss. Тесты на момент WIP: 434/2949/0 (гейт pack_p12.sh).

## 2026-10-18 — P12.17 i18n: Finnish (fi) 100% + ADR-016 per-step архив

**fi complete: 1409/1409 platform keys.** `_platform.fi.json` создан с нуля,
батчи 1→7b; частичный файл дублировался после каждого батча; WIP-коммиты
`265ade7`(188)→`99b5778`(373)→`f282f44`(483)→`08a8171`(573)→`337a1bf`(685)→
`5ab4ebb`(835)→`018067e`(936)→`16ba433`(1031)→`540eabe`(1125)→`9cd8e0d`(1244)→
`051bc75`(1342)→`be52f01`(1409+build).

**Регистр = неформальный, притяжательные суффиксы** (legacy `fi.php`
«IP-osoitteesi»): home.hero_title «Linkkisi, tilastosi», cta «Luo
aliverkkotunnuksesi». После числительных — партитив ед. ч. («{count} päivää
sitten», «{count} linkkiä»). Placeholder QA = 0; missing-keys QA = 0.

**НОВЫЙ ритуал (ADR-016, rule 10, директива пользователя 2026-10-18 «уезжаю
на 8 часов»): после КАЖДОГО шага** — `bash scripts/pack_p12_quick.sh`:
ротация `P12.tar.gz → P12_PRE.tar.gz` + быстрая переупаковка (~2 с, без
tests-гейта; полный гейт — на чекпоинте локали). Манифест получает строку
`wip locales` с точным числом ключей незавершённых локалей. Ротация встроена
и в полный `pack_p12.sh`.

**Верификация.** `build_i18n.php --locale=fi` → собран; `--check` →
**full coverage: 19/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=fi`:
home 200 `<title>Linkkisi, tilastosi — iya.ru</title>`, `lang="fi"`, nav
«Kirjaudu sisään»/«Agenttiparvi»; /about 200 «Tietoja projektista»;
/auth/login 200 «Unohditko salasanasi?»/«Ei tiliä vielä»; /dashboard
302→/auth/login. Регрессий нет: da «Dine links…» на месте. `backup_i18n.sh fi`.

**Следующий шаг (§9):** P12.18 — Czech (cs): тон по legacy `cs.php`
(проверить ty/Vy); на cs сработает **MILESTONE-20** (backup_i18n.sh сам
запустит sync + зеркала /mnt/oss). Далее sk, sl, ro, hu…

## 2026-10-18 — P12.18 i18n: Czech (cs) 100% + MILESTONE-20 🎯

**cs complete: 1409/1409 platform keys.** `_platform.cs.json` создан с нуля,
батчи 1a→7b; WIP-коммиты `91702c8`(129)→`1e1252a`(188)→`e99bd66`(286)→
`4ff1fb4`(373)→`8caaac7`(483)→`e9b4e52`(573)→`dba3898`(685)→`be08403`(835)→
`931c737`(936)→`0f60a37`(1031)→`f4036e0`(1125)→`ec31440`(1244)→`d07a36b`(1342)→
`d237945`(1409+build). После каждого батча — `pack_p12_quick.sh` (правило 10).

**Регистр = ФОРМАЛЬНОЕ vykání** (legacy `cs.php` «Vaše IP adresa», а не ty как
предполагал §9 — исправлено на батче 1b, `_meta` поправлена; батч 1a состоял
из нейтральных существительных/инфинитивов, переделка не потребовалась):
home.hero_title «Vaše odkazy, vaše statistiky». Множественное число глаголов =
вежливая форма («Zapomněli jste heslo?»). Placeholder QA = 0; missing QA = 0.

**Верификация.** `build_i18n.php --locale=cs` → собран; `--check` →
**full coverage: 20/66** = MILESTONE-20. Тесты 434/2949/0 · lint 214/0.
Live `?lang=cs`: home 200 `<title>Vaše odkazy, vaše statistiky — iya.ru</title>`,
`lang="cs"`, nav «Přihlásit se»/«Roj agentů»; /about 200 «O projektu»;
/auth/login 200 «Zapomněli jste heslo?»/«Ještě nemáte účet?»; /dashboard
302→login. Регрессий нет: fi «Linkkisi…» на месте.

**MILESTONE-20** сработал автоматически (`backup_i18n.sh cs`): sync.sh →
versioned `iya-platform_20261018T073132Z_d237945.tar.gz` → зеркала
/mnt/oss/iya-platform-backups/ + маркер `backup/i18n/.milestone-20`.

**Итог сессии 2026-10-18:** восстановление после сбоя №2 + 8 языков
(ja/ko/nl/sv/no/da/fi/cs) = 11272 перевода, покрытие 12/66 → **20/66**.

**Следующий шаг (§9):** P12.19 — Slovak (sk): тон по legacy `sk.php`
(проверить ty/Vy — словацкий UI обычно ty); далее sl, ro, hu, el, bg…
Milestone-30 — на 30-м языке.

## 2026-10-18 — P12.19 i18n: Slovak (sk) 100%

**sk complete: 1409/1409 platform keys.** `_platform.sk.json` с нуля, батчи
1a→7b; WIP-коммиты `ca5d4ed`(129)→`ed615b2`(188)→`53d0688`(286)→`5ea4390`(373)→
`5eae55f`(483)→`56419fb`(573)→`0330502`(685)→`5f4e830`(835)→`cda0ef2`(936)→
`38debd9`(1031)→`5d416c6`(1125)→`2b93b52`(1244)→`7c1b50e`(1342)→`9ac860c`(1409).
После каждого батча — `pack_p12_quick.sh` (правило 10, P12→P12_PRE ротация).

**Регистр = ФОРМАЛЬНОЕ vykanie** (legacy `sk.php` «Vaša IP adresa» — §9
предполагал ty, ошибка исправлена на батче 1b; `_meta` поправлена):
home.hero_title «Vaše odkazy, vaše štatistiky». **Урок зафиксирован в §9:
всегда grep'ать legacy <code>.php your_ip ПЕРЕД батчем 1a.** Placeholder
QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=sk` → собран; `--check` →
**full coverage: 21/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=sk`:
home 200 `<title>Vaše odkazy, vaše štatistiky — iya.ru</title>`, `lang="sk"`,
nav «Prihlásiť sa»/«Roj agentov»; /about 200 «O projekte»; /auth/login 200
«Zabudli ste heslo?»/«Ešte nemáte účet?»; /dashboard 302→login. Регрессий
нет: cs «Vaše odkazy, vaše statistiky» на месте. `backup_i18n.sh sk`.

**Следующий шаг (§9):** P12.20 — Slovenian (sl): тон по legacy `sl.php`
(проверить ty/vi!); далее ro, hu, el, bg, hr, sr, lt… Milestone-30 — на
30-м языке.

## 2026-10-18 — P12.20 i18n: Slovenian (sl) 100%

**sl complete: 1409/1409 platform keys.** `_platform.sl.json` с нуля, батчи
1a→7b; WIP-коммиты `1a264e4`(129)→`76b00d6`(188)→`9ecbeca`(286)→`b432a6b`(373)→
`db34bea`(483)→`9c75790`(573)→`c600730`(685)→`25c087b`(835)→`3a7f084`(936)→
`2e0d239`(1031)→`dafd512`(1125)→`d9d14a0`(1244)→`937a795`(1342)→`8dc7c44`(1409).
После каждого батча — `pack_p12_quick.sh` (P12→P12_PRE ротация, правило 10).

**Регистр = формальное vikanje** (legacy `sl.php` «Vaš IP naslov» — проверен
ДО батча 1a, урок sk/cs усвоен): home.hero_title «Vaše povezave, vaša
statistika». Словенская двойственная форма не задействована ({count}-фразы
упрощены). Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=sl` → собран; `--check` →
**full coverage: 22/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=sl`:
home 200 `<title>Vaše povezave, vaša statistika — iya.ru</title>`, `lang="sl"`,
nav «Prijava»/«Roj agentov»; /about 200 «O projektu»; /auth/login 200
«Ste pozabili geslo?»/«Še nimate računa?»; /dashboard 302→login. Регрессий
нет: sk «Vaše odkazy…» на месте. `backup_i18n.sh sl`.

**Следующий шаг (§9):** P12.21 — Romanian (ro): тон по legacy `ro.php`
(grep your_ip ПЕРЕД батчем 1a: «adresa ta»=tu vs «dumneavoastră»=formal);
далее hu, el, bg, hr, sr… Milestone-30 — на 30-м языке.

## 2026-10-18 — P12.21 i18n: Romanian (ro) 100%

**ro complete: 1409/1409 platform keys.** `_platform.ro.json` с нуля, батчи
1a→7b; WIP-коммиты `0b50ef9`(129)→`6d73d94`(188)→`761d6ad`(286)→`7f84e0c`(373)→
`09e6eaf`(483)→`b6cf4b6`(573)→`70fb11b`(685)→`f875412`(835)→`ed600fb`(936)→
`5a41e8d`(1031)→`e617c48`(1125)→`61687ad`(1244)→`5f0eece`(1342)→`94eca5a`(1409).
После каждого батча — `pack_p12_quick.sh` (P12→P12_PRE ротация).

**Регистр = формальный dvs./dumneavoastră** (legacy `ro.php` «Adresa dvs. IP»
— проверен ДО батча 1a): home.hero_title «Linkurile dvs., statisticile dvs.».
Румынские кавычки „…” в шаблонах, диакритика ă/â/î/ș/ț сохранена. Placeholder
QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=ro` → собран; `--check` →
**full coverage: 23/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=ro`:
home 200 `<title>Linkurile dvs., statisticile dvs. — iya.ru</title>`,
`lang="ro"`, nav «Autentificare»/«Roi de agenți»; /about 200 «Despre proiect»;
/auth/login 200 «Ați uitat parola?»/«Încă nu aveți cont?»; /dashboard
302→login. Регрессий нет: sl «Vaše povezave…» на месте. `backup_i18n.sh ro`.

**Следующий шаг (§9):** P12.22 — Hungarian (hu): тон по legacy `hu.php`
(grep your_ip ПЕРЕД батчем 1a — венгерский UI обычно te, но ПРОВЕРИТЬ);
далее el, bg, hr, sr, lt, lv, et… Milestone-30 — на 30-м языке.

## 2026-10-18 — P12.22 i18n: Hungarian (hu) 100%

**hu complete: 1409/1409 platform keys.** `_platform.hu.json` с нуля, батчи
1a→7b; WIP-коммиты `425db4e`(129)→`710c956`(188)→`c063a7b`(286)→`5349e5e`(373)→
`5e77cdb`(483)→`0d50335`(573)→`196afcd`(685)→`924b97f`(835)→`5faf2d9`(936)→
`028004d`(1031)→`45035ec`(1125)→`a1e4ca7`(1244)→`1771e47`(1342)→`de4d8f5`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальное Önözés** (legacy `hu.php` «Az Ön IP-címe» — проверен
ДО батча 1a): home.hero_title «Az Ön linkjei, az Ön statisztikái». Венгерская
агглютинация сохранена («{count} napja», «{count} linkre alkalmazva»);
«» для цитат. Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=hu` → собран; `--check` →
**full coverage: 24/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=hu`:
home 200 `<title>Az Ön linkjei, az Ön statisztikái — iya.ru</title>`,
`lang="hu"`, nav «Bejelentkezés»/«Ügynökraj»; /about 200 «A projekt névjegye»;
/auth/login 200 «Elfelejtette jelszavát?»/«Még nincs fiókja?»; /dashboard
302→login. Регрессий нет: ro «Linkurile dvs.…» на месте. `backup_i18n.sh hu`.

**Следующий шаг (§9):** P12.23 — Greek (el): тон по legacy `el.php`
(grep your_ip ПЕРЕД батчем 1a; греческий UI обычно формальный σας);
далее bg, hr, sr, lt, lv, et… Milestone-30 — на 30-м языке.

## 2026-10-18 — P12.23 i18n: Greek (el) 100%

**el complete: 1409/1409 platform keys.** `_platform.el.json` с нуля, батчи
1a→7b; WIP-коммиты `d09d6ff`(129)→`9791d3d`(188)→`f36378a`(286)→`c03a5b2`(373)→
`0bd4722`(483)→`73d3255`(573)→`641bfa6`(685)→`39ab8ad`(835)→`cece429`(936)→
`5b7a926`(1031)→`133f133`(1125)→`bf8b6ee`(1244)→`cedbb95`(1342)→`31dc03b`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальная множественная Σας** (legacy `el.php` «Η Διεύθυνση IP
Σας» — проверен ДО батча 1a): home.hero_title «Οι σύνδεσμοί Σας, τα
στατιστικά Σας». Греческий семиколон «;» как вопросительный знак сохранён в
«Ξεχάσατε τον κωδικό σας;». Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=el` → собран; `--check` →
**full coverage: 25/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=el`:
home 200 `<title>Οι σύνδεσμοί Σας, τα στατιστικά Σας — iya.ru</title>`,
`lang="el"`, nav «Σύνδεση»/«Σμήνος πρακτόρων»; /about 200 «Σχετικά με το
έργο»; /auth/login 200 «Ξεχάσατε τον κωδικό σας?»/«Δεν έχετε λογαριασμό
ακόμα»; /dashboard 302→login. Регрессий нет: hu на месте. `backup_i18n.sh el`.

**Итог сессии 2026-10-18 (полный):** восстановление после сбоя №2 + 13
языков (ja/ko/nl/sv/no/da/fi/cs/sk/sl/ro/hu/el) = 18317 переводов,
покрытие 12/66 → **25/66**. До milestone-30: 5 языков (bg/hr/sr/lt/lv).

**Следующий шаг (§9):** P12.24 — Bulgarian (bg): тон по legacy `bg.php`
(grep your_ip ПЕРЕД батчем 1a: «Вашият»=formal vs «Твоят»=informal); далее
hr, sr, lt, lv, et… Milestone-30 сработает автоматически.

## 2026-10-18 — P12.24 i18n: Bulgarian (bg) 100%

**bg complete: 1409/1409 platform keys.** `_platform.bg.json` с нуля, батчи
1a→7b; WIP-коммиты `ea85e56`(129)→`160504d`(188)→`5dfab6d`(286)→`43733c6`(373)→
`380f001`(483)→`bbe24fb`(573)→`73260cf`(685)→`c025783`(835)→`3b360ab`(936)→
`7b41a8a`(1031)→`a5a630c`(1125)→`753ec88`(1244)→`7f865be`(1342)→`514d87e`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальное Ви** (legacy `bg.php` «Вашият IP адрес» — проверен ДО
батча 1a): home.hero_title «Вашите връзки, Вашата статистика». Кавычки „…"
сохранены; {count}-фразы («преди {count} дни»). Placeholder QA = 0;
missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=bg` → собран; `--check` →
**full coverage: 26/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=bg`:
home 200 `<title>Вашите връзки, Вашата статистика — iya.ru</title>`,
`lang="bg"`, nav «Вход»/«Рояк агенти»; /about 200 «За проекта»; /auth/login
200 «Забравена парола?»/«Нямате акаунт?»; /dashboard 302→login. Регрессий
нет: el на месте. `backup_i18n.sh bg`.

**Следующий шаг (§9):** P12.25 — Croatian (hr): тон УЖЕ проверен —
legacy `hr.php` «Vaša IP adresa» → формальный Vi; далее sr (кириллица,
«Ваша IP адреса» → формальный Ви), lt, lv, et… Milestone-30 — на 30-м языке
(hr+sr+lt+lv — сработает автоматически).

## 2026-10-18 — P12.25 i18n: Croatian (hr) 100%

**hr complete: 1409/1409 platform keys.** `_platform.hr.json` с нуля, батчи
1a→7b; WIP-коммиты `01665fb`(129)→`36948d2`(188)→`f482f84`(286)→`44d7a9c`(373)→
`136d730`(483)→`1d4a7ee`(573)→`1d02fb0`(685)→`7a204f4`(835)→`8af835e`(936)→
`241cb2b`(1031)→`d071dd0`(1125)→`fedfe06`(1244)→`57f37a6`(1342)→`e2d5f3c`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальный Vi** (legacy `hr.php` «Vaša IP adresa» — тон был
предпроверен ещё на чекпоинте bg): home.hero_title «Vaše poveznice, vaša
statistika». Хорватская лексика: poveznica (не link), lozinka (не password),
preuzimanje (download). Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=hr` → собран; `--check` →
**full coverage: 27/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=hr`:
home 200 `<title>Vaše poveznice, vaša statistika — iya.ru</title>`,
`lang="hr"`, nav «Prijava»/«Roj agenata»; /about 200 «O projektu»;
/auth/login 200 «Zaboravili ste lozinku?»/«Još nemate račun?»; /dashboard
302→login. Регрессий нет: bg на месте. `backup_i18n.sh hr`.

**Следующий шаг (§9):** P12.26 — Serbian (sr, КИРИЛЛИЦА): тон предпроверен —
legacy `sr.php` «Ваша IP адреса» → формальный Ви; писать кириллицей
(«Линкови», «Статистике» как в legacy). Далее lt, lv, et… Milestone-30 —
на 30-м языке (sr+lt+lv).

## 2026-10-18 — P12.26 i18n: Serbian (sr) 100% — КИРИЛЛИЦА

**sr complete: 1409/1409 platform keys.** `_platform.sr.json` с нуля, батчи
1a→7b; WIP-коммиты `741f6ac`(129)→`eacedb7`(188)→`edb1d04`(286)→`6b8d7b2`(373)→
`c0a30b6`(483)→`d1e7ceb`(573)→`482d42c`(685)→`f9fccb7`(835)→`6f794c8`(936)→
`1a97dda`(1031)→`ea0e073`(1125)→`f25f1d5`(1244)→`2d8ccff`(1342)→`b3267b2`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальный Ви, КИРИЛЛИЦА** (legacy `sr.php` «Ваша IP адреса» —
предпроверен на чекпоинте hr): home.hero_title «Ваши линкови, ваша
статистика». Кириллическая лексика (Линкови/Статистике/Поддомен), «» и „…"
кавычки. Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=sr` → собран; `--check` →
**full coverage: 28/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=sr`:
home 200 `<title>Ваши линкови, ваша статистика — iya.ru</title>`, `lang="sr"`,
nav «Пријава»/«Рој агената»; /about 200 «О пројекту»; /auth/login 200
«Заборавили сте лозинку?»/«Још немате налог»; /dashboard 302→login.
Регрессий нет: hr на месте. `backup_i18n.sh sr`.

**Следующий шаг (§9):** P12.27 — Lithuanian (lt): тон по legacy `lt.php`
(grep your_ip ПЕРЕД батчем 1a — литовский UI обычно неформальный tu, НО
ПРОВЕРИТЬ); далее lv, et… **Milestone-30 сработает через 2 языка (lt+lv)**.

## 2026-10-18 — P12.27 i18n: Lithuanian (lt) 100%

**lt complete: 1409/1409 platform keys.** `_platform.lt.json` с нуля, батчи
1a→7b; WIP-коммиты `02cb552`(129)→`5c8b8e6`(188)→`16544da`(286)→`05de2a7`(373)→
`1cfcbe5`(483)→`0a5182b`(573)→`9d4c615`(685)→`9e98f94`(835)→`a0cc119`(936)→
`7b9c9fa`(1031)→`19881dd`(1125)→`29503d0`(1244)→`57c4427`(1342)→`5256360`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальный Jūsų** (legacy `lt.php` «Jūsų IP adresas» — проверен
ДО батча 1a; §9 предполагал tu — в четвёртый раз предположение оказалось
неверным, правило «grep первым» спасает): home.hero_title «Jūsų nuorodos,
jūsų statistika». Литовская диакритика (ą/č/ę/ė/į/š/ū/ž) сохранена.
Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=lt` → собран; `--check` →
**full coverage: 29/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=lt`:
home 200 `<title>Jūsų nuorodos, jūsų statistika — iya.ru</title>`, `lang="lt"`,
nav «Prisijungti»/«Agentų spiečius»; /about 200 «Apie projektą»; /auth/login
200 «Pamiršote slaptažodį?»/«Dar neturite paskyros?»; /dashboard 302→login.
Регрессий нет: sr «Ваши линкови…» на месте. `backup_i18n.sh lt`.

**Следующий шаг (§9):** P12.28 — Latvian (lv): тон УЖЕ проверен —
legacy `lv.php` «Jūsu IP adrese» → формальный Jūsu; **на lv сработает
MILESTONE-30** (backup_i18n.sh сам запустит sync + зеркала /mnt/oss).
Далее et (ИНФОРМАЛЬНЫЙ «Sinu IP aadress» — проверен), is, ga, mt, fil…

## 2026-10-18 — P12.28 i18n: Latvian (lv) 100% + MILESTONE-30 🎯

**lv complete: 1409/1409 platform keys.** `_platform.lv.json` с нуля, батчи
1a→7b; WIP-коммиты `8004c94`(129)→`bfbb31f`(188)→`3b5e111`(286)→`65f149c`(373)→
`139ffcb`(483)→`453fda0`(573)→`09c1ac9`(685)→`1d0ebe8`(835)→`a89bf41`(936)→
`17e04ef`(1031)→`182dddd`(1125)→`86c5fde`(1244)→`19ce27c`(1342)→`abe8984`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = формальный Jūs** (legacy `lv.php` «Jūsu IP adrese» —
предпроверен на sr-чекпоинте): home.hero_title «Jūsu saites, jūsu
statistika». Латвийская диакритика (ā/č/ē/ģ/ī/ķ/ļ/ņ/š/ū/ž) сохранена.
Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=lv` → собран; `--check` →
**full coverage: 30/66 = MILESTONE-30**. Тесты 434/2949/0 · lint 214/0.
Live `?lang=lv`: home 200 `<title>Jūsu saites, jūsu statistika — iya.ru</title>`,
`lang="lv"`, nav «Pieteikties»/«Aģentu bars»; /about 200 «Par projektu»;
/auth/login 200 «Aizmirsāt paroli?»/«Vēl nav konta»; /dashboard 302→login.
Регрессий нет: lt «Jūsų nuorodos…» на месте.

**MILESTONE-30** сработал автоматически (`backup_i18n.sh lv`): sync.sh →
versioned tarball → зеркала /mnt/oss/iya-platform-backups/ + маркер
`backup/i18n/.milestone-30`.

**Итог сессии 2026-10-18 (обновлён):** восстановление после сбоя №2 + 18
языков (ja/ko/nl/sv/no/da/fi/cs/sk/sl/ro/hu/el/bg/hr/sr/lt/lv) = 25362
перевода, покрытие 12/66 → **30/66** (почти половина!).

**Следующий шаг (§9):** P12.29 — Estonian (et): тон УЖЕ проверен —
legacy `et.php` «Sinu IP aadress» → ИНФОРМАЛЬНЫЙ sina (первый неформальный
со времён da/no/fi; как fi — притяжательные формы sinu/oma). Далее is, ga,
mt, fil, ar, he, fa… Milestone-40 — на 40-м языке.

## 2026-10-18 — P12.29 i18n: Estonian (et) 100%

**et complete: 1409/1409 platform keys.** `_platform.et.json` с нуля, батчи
1a→7b; WIP-коммиты `d68a3ea`(129)→`626446f`(188)→`f0799e1`(286)→`189d71c`(373)→
`218864c`(483)→`9832cb2`(573)→`6cddea2`(685)→`1b8e833`(835)→`465a694`(936)→
`5db8f01`(1031)→`e6d197c`(1125)→`aef9ae4`(1244)→`397b9c4`(1342)→`152aad6`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = ИНФОРМАЛЬНЫЙ sina** (legacy `et.php` «Sinu IP aadress» —
предпроверен на sr-чекпоинте; первый неформальный со времён fi):
home.hero_title «Sinu lingid, sinu statistika». Эстонская диакритика
(ä/ö/õ/ü) сохранена. Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=et` → собран; `--check` →
**full coverage: 31/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=et`:
home 200 `<title>Sinu lingid, sinu statistika — iya.ru</title>`, `lang="et"`,
nav «Logi sisse»/«Agentide parv»; /about 200 «Projektist»; /auth/login 200
«Unustasid parooli?»/«Pole veel kontot?»; /dashboard 302→login. Регрессий
нет: lv на месте. `backup_i18n.sh et`.

**Следующий шаг (§9):** P12.30 — Icelandic (is): legacy `is.php` может
отсутствовать (проверить!) — при отсутствии использовать неформальный þú
по современной UI-конвенции; далее ga, mt, fil, ar, he, fa… Milestone-40 —
на 40-м языке.

## 2026-10-18 — P12.30 i18n: Icelandic (is) 100%

**is complete: 1409/1409 platform keys.** `_platform.is.json` с нуля, батчи
1a→7b; WIP-коммиты `ede1292`(129)→`f08e45d`(188)→`ef34ef6`(286)→`c0e5bcd`(373)→
`fe34fe5`(483)→`db7f39b`(573)→`43b8be3`(685)→`25480fb`(835)→`ba1f747`(936)→
`59a13b0`(1031)→`cacdadd`(1125)→`548becf`(1244)→`7cfc268`(1342)→`c46b415`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = ИНФОРМАЛЬНЫЙ þú** (legacy `is.php` «IP-tölfan þín» — предпроверен
на et-чекпоинте): home.hero_title «Þínir tenglar, þín tölfræði». Исландская
диакритика (ð/þ/æ/ö) сохранена. Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=is` → собран; `--check` →
**full coverage: 32/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=is`:
home 200 `<title>Þínir tenglar, þín tölfræði — iya.ru</title>`, `lang="is"`,
nav «Skrá inn»/«Sveimur umboðsmanna»; /about 200 «Um verkefnið»; /auth/login
200 «Gleymt lykilorð?»/«Ertu ekki með reikning»; /dashboard 302→login.
Регрессий нет: et на месте. `backup_i18n.sh is`.

**Итог сессии 2026-10-18 (обновлён):** 20 языков (ja/ko/nl/sv/no/da/fi/cs/sk/
sl/ro/hu/el/bg/hr/sr/lt/lv/et/is) = 28180 переводов, покрытие 12/66 → **32/66**.

**Следующий шаг (§9):** P12.31 — Irish (ga): тон по legacy `ga.php` (grep
your_ip ПЕРЕД батчем 1a); далее mt, fil, ar, he, fa (ar/he/fa = RTL:
`direction: rtl` в _meta!). Milestone-40 — на 40-м языке.

## 2026-10-18 — P12.31 i18n: Irish (ga) 100% — ПОЛОВИНА ПУТИ 33/66

**ga complete: 1409/1409 platform keys.** `_platform.ga.json` с нуля, батчи
1a→7b; WIP-коммиты `251939b`(129)→`41d6cd8`(188)→`7562e9a`(286)→`a00a480`(373)→
`73d2960`(483)→`7b372dc`(573)→`30ca3e7`(685)→`ac347b9`(835)→`1284390`(936)→
`7decd6b`(1031)→`a38d865`(1125)→`b251eb4`(1244)→`7f2e0b1`(1342)→`14d6b29`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = ИНФОРМАЛЬНЫЙ tú** (legacy `ga.php` «Do Sheoladh IP» — предпроверен
на is-чекпоинте): home.hero_title «Do naisc, do chuid staitisticí». Ирландская
диакритика (á/é/í/ó/ú + lenition: fhlódhómain, shínithe) и eclipses сохранены.
Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=ga` → собран; `--check` →
**full coverage: 33/66 = РОВНО ПОЛОВИНА**. Тесты 434/2949/0 · lint 214/0.
Live `?lang=ga`: home 200 `<title>Do naisc, do chuid staitisticí — iya.ru</title>`,
`lang="ga"`, nav «Logáil isteach»/«Scaoth gníomhairí»; /about 200 «Maidir leis
an tionscadal»; /auth/login 200 «Dearmad ar do phasfhocal?»/«Nach bhfuil
cuntas agat fós»; /dashboard 302→login. Регрессий нет: is на месте.
`backup_i18n.sh ga`.

**Итог сессии 2026-10-18:** 21 язык (ja/ko/nl/sv/no/da/fi/cs/sk/sl/ro/hu/el/
bg/hr/sr/lt/lv/et/is/ga) = 29589 переводов, покрытие 12/66 → **33/66**.

**Следующий шаг (§9):** P12.32 — Maltese (mt): тон УЖЕ проверен —
legacy «L-Indirizz IP Tiegħek» → неформальный tiegħek; далее fil (iyong),
ar/he/fa (RTL: `direction: rtl` в _meta + grep your_ip). Milestone-40 —
на 40-м языке.

## 2026-10-18 — P12.32 i18n: Maltese (mt) 100%

**mt complete: 1409/1409 platform keys.** `_platform.mt.json` с нуля, батчи
1a→7b; WIP-коммиты `c9a9e65`(129)→`2bff467`(188)→`cf52ea5`(286)→`543c99a`(373)→
`3cc04a1`(483)→`4733478`(573)→`6c7a92f`(685)→`c1d44e7`(835)→`4f63836`(936)→
`8f6007a`(1031)→`b4f36b4`(1125)→`4a1cfeb`(1244)→`01ae97c`(1342)→`c5f53e7`(1409).
После каждого батча — `pack_p12_quick.sh`.

**Регистр = неформальный tiegħek** (legacy `mt.php` «L-Indirizz IP Tiegħek» —
предпроверен на ga-чекпоинте): home.hero_title «Il-links tiegħek,
l-istatistika tiegħek». Мальтийская диакритика (ċ/ġ/ħ/ż) + семитские корни
сохранены; английские заимствования для tech-терминов (token, webhook, API)
как в legacy. Placeholder QA = 0; missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=mt` → собран; `--check` →
**full coverage: 34/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=mt`:
home 200 `<title>Il-links tiegħek, l-istatistika tiegħek — iya.ru</title>`,
`lang="mt"`, nav «Idħol»/«Swarm ta' aġenti»; /about 200 «Dwar il-proġett»;
/auth/login 200 «Insejt il-password?» (апостроф в «Għad m'għandekx kont?»
экранируется как &#039; в HTML — норма); /dashboard 302→login. Регрессий
нет: ga на месте. `backup_i18n.sh mt`.

**Итог сессии 2026-10-18:** 22 языка = 30998 переводов, 12/66 → **34/66**.

**Следующий шаг (§9):** P12.33 — Filipino (fil): тон УЖЕ проверен —
«Iyong IP Address» → неформальный iyong/mo (смесь тагальского с английскими
loanwords как в legacy); далее ar/he/fa (RTL: `direction: rtl` в _meta),
hi, bn… Milestone-40 — на 40-м языке.

## 2026-10-18 — P12.33 i18n: Filipino (fil) 100%

**fil complete: 1409/1409 platform keys.** `_platform.fil.json` с нуля,
батчи 1a→7b; WIP-коммиты `2f319d5`(129)→`7f516ec`(188)→`983c942`(286)→
`45d7c45`(373)→`db89d1d`(483)→`6a8813e`(573)→`3534e52`(685)→`6edfb9f`(835)→
`f483931`(936)→`a814c18`(1031)→`f9fb064`(1125)→`4b7ea18`(1244)→`752ddab`(1342)→
`79eb744`(1409). После каждого батча — `pack_p12_quick.sh`.

**Регистр = неформальный iyong/ang-iyong** (legacy `fil.php` «Iyong IP
Address» — предпроверен на ga-чекпоинте): home.hero_title «Ang iyong mga
link, ang iyong istatistika». Taglish: tech-loanwords сохранены как в
legacy (link, password, token, webhook, subdomain). Placeholder QA = 0;
missing-keys QA = 0.

**Верификация.** `build_i18n.php --locale=fil` → собран; `--check` →
**full coverage: 35/66**. Тесты 434/2949/0 · lint 214/0. Live `?lang=fil`:
home 200 `<title>Ang iyong mga link, ang iyong istatistika — iya.ru</title>`,
`lang="fil"`, nav «Mag-login»/«Swarm ng mga ahente»; /about 200 «Tungkol sa
proyekto»; /auth/login 200 «Nakalimutan ang password?»/«Wala pang account»;
/dashboard 302→login. Регрессий нет: mt на месте. `backup_i18n.sh fil`.

**Тоны следующих проверены:** ar = MSA «عنوان IP الخاص بك» (RTL!),
he = «כתובת ה-IP שלך» informal (RTL), fa = «آدرس IP شما» polite shoma (RTL).
Для всех трёх: `"direction":"rtl"` в _meta + проверить dir/lang атрибуты
в live (если тема не отдаёт dir=rtl — записать как P13 follow-up, ядро в
P12 не трогать).

**Следующий шаг (§9):** P12.34 — Arabic (ar, RTL). Milestone-40 — на
40-м языке (ar+he+fa+hi+bn).

## 2026-10-18 — P12.34 i18n: Arabic (ar) 100% — ПЕРВАЯ RTL-ЛОКАЛЬ! 🎉

**ar complete: 1409/1409 platform keys.** `_platform.ar.json` с нуля
(`"direction":"rtl"` в _meta), батчи 1a→7b; WIP-коммиты `4823269`(129)→
`aff8250`(188)→`c0bb320`(286)→`9dadfd6`(373)→`bfe16b4`(483)→`aec4d87`(573)→
`8a8ee68`(685)→`c4f7560`(835)→`66ec06f`(936)→`46d40a2`(1031)→`060b725`(1125)→
`ebd2b6b`(1244)→`ff3244e`(1342)→`2b245a3`(1409). После каждого батча —
`pack_p12_quick.sh`.

**Регистр = MSA, «ك»-стиль** (legacy `ar.php` «عنوان IP الخاص بك»):
home.hero_title «روابطك، إحصاءاتك».

**RTL-движок ПОДТВЕРЖДЁН:** live `?lang=ar` отдаёт
`<html lang="ar" dir="rtl">` — тема автоматически применяет direction из
_meta. P13 follow-up НЕ нужен (запись в §9 была страховкой).

**Верификация.** `--check` → **full coverage: 36/66**. Тесты 434/2949/0 ·
lint 214/0. Live: home 200 «روابطك، إحصاءاتك — iya.ru», dir=rtl, nav
«تسجيل الدخول»/«سرب الوكلاء»; /about 200 «عن المشروع»; /auth/login 200
«نسيت كلمة المرور؟»; /dashboard 302→login. Регрессий нет: fil на месте.
`backup_i18n.sh ar`.

**Следующий шаг (§9):** P12.35 — Hebrew (he, RTL): тон проверен —
«כתובת ה-IP שלך» → неформальный «שלך»; `"direction":"rtl"` в _meta.
Далее fa (RTL, вежливый «شما»), hi, bn… Milestone-40 — на 40-м языке
(he+fa+hi+bn).

## 2026-10-18 (15:2x local) — ВОССТАНОВЛЕНИЕ ПОСЛЕ СБОЯ (sandbox reset #2)

`"$ARENA_WORKSPACE"` пришёл пустым (снапшот не пережил сбой). Восстановление по
Recovery checklist + ENV §5–6:

* источник: `/mnt/oss/iya-platform-backups/iya-platform_20261018T122535Z_adf973c.tar.gz`
  (свежайший durable, 12:25 UTC) — распакован в `"$ARENA_WORKSPACE"/iya-platform`,
  **.git пережил тарболл**: история цела, HEAD=`adf973c`, дерево чистое;
* runtime: `apt-get install php-cli php-sqlite3 php-mbstring php-xml php-curl
  php-intl php-zip` → PHP 8.2.34, все расширения на месте;
* backup-слои: `backup/i18n/` ← `/mnt/oss/iya-i18n/` (62 файла),
  `backup/versions/` ← свежайший тарболл `adf973c`;
* здоровье: `migrate` OK · тесты **434/2949/0** (17.6 s) · lint **214/0** ·
  `build_i18n.php --check` **66 каталогов / full coverage 36/66** — ровно как
  в §0 на момент сбоя, регрессий нет;
* корневой архив (rule 6): `"$ARENA_WORKSPACE"/P12.tar.gz` пересобран `pack_p12.sh`
  + зеркалирован с манифестом в `/mnt/oss/iya-platform-backups/`;
* следующий шаг (§9): **P12.35 Hebrew (he, RTL)**, informal «שלך»,
  `"direction":"rtl"` в `_meta`, батчи 1→7, `pack_p12_quick.sh` после каждого.

## 2026-10-18 — P12.35 i18n: Hebrew (he) 100% — вторая RTL-локаль ✅

**he complete: 1409/1409 platform keys.** `_platform.he.json` с нуля
(`"direction":"rtl"` в _meta), 7 батчей (188/185/200/322/230/144/140);
WIP-коммиты batch1→batch7, после каждого — `pack_p12_quick.sh`.

**Регистр = неформальный, м.р. ед.ч.** (legacy `he.php` «כתובת ה-IP שלך»,
«קבל מידע מלא»): home.hero_title «הקישורים שלך, הסטטיסטיקה שלך»;
техтермины латиницей где legacy так делает (API, Webhook, localStorage),
ивритские лоанворды: טוקן, סאבדומיין, דפדפן.

**Верификация.** build → `full coverage: 37/66`. Placeholder QA **0**,
missing/extra QA **0/0**. Тесты **434/2949/0** · lint **214/0**.
Live (python urllib — curl в свежей песочнице нет): home 200
`<html lang="he" dir="rtl">` «הקישורים שלך, הסטטיסטיקה שלך — iya.ru»,
about 200 «אודות הפרויקט», login 200 «שכחת סיסמה?»/«ברוך שובך»,
dashboard 302→/auth/login. No-регрессия: ar (rtl) ✓, en ✓, fil ✓.
Коммит `f5b83b6`; `backup_i18n.sh he` → backup/i18n + /mnt/oss/iya-i18n.

**Следующий шаг (§9):** P12.36 — Persian (fa, RTL): тон проверен —
«آدرس IP شما» → вежливый shoma; `"direction":"rtl"` в _meta. Далее hi, bn
→ Milestone-40 (auto). Затем vi, th, id, ms, az, af, bs, ca.

## 2026-10-18 — P12.36 i18n: Persian (fa) 100% — третья RTL-локаль ✅

**fa complete: 1409/1409 platform keys.** `_platform.fa.json` с нуля
(`"direction":"rtl"` в _meta), 7 батчей (188/185/200/322/230/144/140);
WIP-коммиты batch1→batch7, после каждого — `pack_p12_quick.sh`.

**Регистр = вежливый shoma** (legacy `fa.php` «آدرس IP شما»):
home.hero_title «لینک‌های شما، آمار شما»; техтермины латиницей
(API, Webhook, localStorage, QR), персидские лоанворды: توکن، زیردامنه،
پیشخوان. Персидские цифры в фиксированных числах (۷/۳۰/۹۰ روز), {count}-
плейсхолдеры не тронуты. ZWNJ (‌) в составе слов — по нормам персидской
орфографии.

**Верификация.** build → `full coverage: 38/66`. Placeholder QA **0**,
missing/extra **0/0**. Тесты **434/2949/0** · lint **214/0**. Live (urllib):
home 200 `<html lang="fa" dir="rtl">` «لینک‌های شما، آمار شما — iya.ru»,
about 200 «دربارهٔ پروژه», login 200 «رمز عبور خود را فراموش کرده‌اید؟» /
«خوش برگشتید», dashboard 302→/auth/login. No-регрессия: he ✓, en ✓.
Коммит `6831510`; `backup_i18n.sh fa` → backup/i18n + /mnt/oss/iya-i18n.

**Следующий шаг (§9):** P12.37 — Hindi (hi): тон проверен — «आपका IP पता»
→ вежливый aap; LTR. Затем bn (formal «আপনার আইপি ঠিকানা») → на bn сработает
**Milestone-40** (auto sync + зеркала). Далее vi, th, id, ms, az, af, bs, ca.

## 2026-10-18 — P12.37 i18n: Hindi (hi) 100% ✅

**hi complete: 1409/1409 platform keys.** `_platform.hi.json` с нуля
(`"direction":"ltr"` в _meta, как у de), 7 батчей (188/185/200/322/230/144/140);
WIP-коммиты batch1→batch7, после каждого — `pack_p12_quick.sh`.

**Регистр = вежливый aap** (legacy `hi.php` «आपका IP पता»):
home.hero_title «आपके लिंक, आपके आँकड़े»; деванагари, техтермины латиницей
(API, Webhook, token, QR), хинди-лоанворды: सबडोमेन, डैशबोर्ड, एजेंट।

**Верификация.** build → `full coverage: 39/66`. Placeholder QA **0**,
missing/extra **0/0**. Тесты **434/2949/0** · lint **214/0**. Live (urllib):
home 200 `<html lang="hi" dir="ltr">` «आपके लिंक, आपके आँकड़े — iya.ru»,
about 200 «प्रोजेक्ट के बारे में», login 200 «पासवर्ड भूल गए?»/«वापसी पर
स्वागत है», dashboard 302→/auth/login. No-регрессия: fa ✓, ar ✓, en ✓.
Коммит `cf8c6b7`; `backup_i18n.sh hi` → backup/i18n + /mnt/oss/iya-i18n.

**Следующий шаг (§9):** P12.38 — Bengali (bn): тон проверен — «আপনার আইপি
ঠিকানা» → формальный apni; LTR. **На bn сработает Milestone-40** (auto
sync + зеркала). Далее vi, th, id, ms, az, af, bs, ca.

## 2026-10-18 — P12.38 i18n: Bengali (bn) 100% — 🏁 MILESTONE-40! 

**bn complete: 1409/1409 platform keys.** `_platform.bn.json` с нуля
(`"direction":"ltr"`), 7 батчей (188/185/200/322/230/144/140); WIP-коммиты
batch1→batch7 + фикс случайно затесавшегося деванагари-символа в
settings.show_public_org; после каждого батча — `pack_p12_quick.sh`.

**Регистр = формальный apni** (legacy `bn.php` «আপনার আইপি ঠিকানা»):
home.hero_title «আপনার লিংক, আপনার পরিসংখ্যান»; бенгальские цифры в
фиксированных числах (৭/৩০/৯০ দিন), {count}-плейсхолдеры не тронуты;
даң্ডা «।» (U+0964) — легитимная бенгальская пунктуация (script-purity
проверка дала 129 «срабатываний» — все на U+0964, ложные).

**Верификация.** build → `full coverage: 40/66`. Placeholder QA **0**,
missing/extra **0/0**. Тесты **434/2949/0** · lint **214/0**. Live (urllib):
home 200 `<html lang="bn" dir="ltr">` «আপনার লিংক, আপনার পরিসংখ্যান — iya.ru»,
about 200 «প্রকল্প সম্পর্কে», login 200 «পাসওয়ার্ড ভুলে গেছেন?»/«আবার
স্বাগতম», dashboard 302→/auth/login. No-регрессия: hi ✓, en ✓.

**MILESTONE-40 сработал АВТОМАТИЧЕСКИ** (`backup_i18n.sh bn`): versioned
тарболл `iya-platform_20261018T163736Z_4b92dca.tar.gz` → backup/versions +
/mnt/oss; P12.tar.gz → /mnt/oss; маркер `.milestone-40`. Коммит `4b92dca`.

**Тоны следующих локалей проверены (legacy grep):** vi «Địa chỉ IP của bạn»
(нейтральный bạn), th «ที่อยู่ IP ของคุณ», id/ms «Alamat IP Anda» (формальный
Anda).

**Следующий шаг (§9):** P12.39 — Vietnamese (vi). Далее th, id, ms, az, af,
bs, ca. Milestone-50 на 50-м языке.

## 2026-10-18 — P12.39 i18n: Vietnamese (vi) 100% ✅

**vi complete: 1409/1409 platform keys.** `_platform.vi.json` с нуля
(`"direction":"ltr"`), 7 батчей (188/185/200/322/230/144/140); WIP-коммиты
batch1→batch7, после каждого — `pack_p12_quick.sh`.

**Регистр = нейтральный bạn** (legacy `vi.php` «Địa chỉ IP của bạn»):
home.hero_title «Liên kết của bạn, thống kê của bạn»; техтермины латиницей
(API, webhook, token, QR). **Новый QA-гейт (введён на этой локали):
cyrillic/wrong-script guard** после каждой записи батча — поймал 2 реальные
опечатки («с» кириллическая в views.share, «мới» с кириллической м в
swarm.feature_status_new) + 1 в admin.invalid_status («не»); все исправлены
до коммита. Правило добавлено в §9 NEXT ACTION для th.

**Верификация.** build → `full coverage: 41/66`. Placeholder QA **0**,
missing/extra **0/0**. Тесты **434/2949/0** · lint **214/0**. Live (urllib):
home 200 `<html lang="vi" dir="ltr">` «Liên kết của bạn, thống kê của bạn —
iya.ru», about 200 «Giới thiệu dự án», login 200 «Quên mật khẩu?»/«Chào mừng
trở lại», dashboard 302→/auth/login. No-регрессия: bn ✓, en ✓.
Коммит vi-complete; `backup_i18n.sh vi` → backup/i18n + /mnt/oss/iya-i18n.

**Следующий шаг (§9):** P12.40 — Thai (th): тон проверен — «ที่อยู่ IP ของคุณ»,
вежливо-нейтральный คุณ; LTR; с script-guard. Далее id/ms (формальный Anda),
az, af, bs, ca. Milestone-50 на 50-м языке.

## 2026-10-18 — P12.40 i18n: Thai (th) 100% ✅

**th complete: 1409/1409 platform keys.** `_platform.th.json` с нуля
(`"direction":"ltr"`), 7 батчей (188/185/200/322/230/144/140); WIP-коммиты
batch1→batch7, после каждого — `pack_p12_quick.sh`; script-guard чист на
каждом батче (правда поймана опечатка «กา»→«การ» в alerts.empty — исправлена
до финального коммита).

**Регистр = вежливо-нейтральный คุณ** (legacy `th.php` «ที่อยู่ IP ของคุณ»):
home.hero_title «ลิงก์ของคุณ สถิติของคุณ»; техтермины латиницей (API,
Webhook, QR), тайские лоанворды: ซับโดเมน, แดชบอร์ด, เอเจนต์, โทเคน.

**Верификация.** build → `full coverage: 42/66`. Placeholder QA **0**,
missing/extra **0/0**. Тесты **434/2949/0** · lint **214/0**. Live (urllib):
home 200 `<html lang="th" dir="ltr">` «ลิงก์ของคุณ สถิติของคุณ — iya.ru»,
about 200 «เกี่ยวกับโปรเจกต์», login 200 «ลืมรหัสผ่าน?»/«ยินดีต้อนรับกลับมา»,
dashboard 302→/auth/login. No-регрессия: vi ✓, en ✓. `backup_i18n.sh th`.

**Отчёт об остатке (rule 11, впервые):** локалей осталось **24/66 (36% P12)**:
id, ms, az, af, bs, ca, hy, ka, kk, km, lb, lo, mk, mn, mr, my, ne, si, sq,
sw, ta, te, ur (RTL), uz. Дорожная карта: P12 64% готов (42/66), P13–P16 не
начаты; общая формула §9 rule 11: готово = (11 + 42/66)/16 фаз ≈ **73%**,
осталось ≈ **27%** общего плана.

**Следующий шаг (§9):** P12.41 — Indonesian (id): тон проверен — «Alamat IP
Anda» → формальный Anda; далее ms (тот же Anda, по аналогии быстрее).
Milestone-50 на 50-м языке (id, ms, az, af, bs, ca, hy — седьмой включит).

## 2026-10-18 — ИЗМЕНЕНИЕ SCOPE + P14: ip_blocks EDGE ENFORCEMENT ✅ (главный пробел безопасности закрыт)

**Scope (ADR-018, решение пользователя):** «оставшиеся языки пропускаем и
доделываем остальной план» → **P12 заморожен на 42/66**; 24 локали
(id, ms, az, af, bs, ca, hy, ka, kk, km, lb, lo, mk, mn, mr, my, ne, si,
sq, sw, ta, te, ur, uz) пропущены, en-fallback покрывает их в рантайме.
Вся i18n-машинерия сохранена; инструкция возобновления — в §9.

**P14 ip_blocks edge enforcement (ADR-017).** Таблица `ip_blocks` существовала
с P11c, но на edge её никто не читал — главный известный пробел. Сделано:

* `src/Security/IpBlockGuard.php`: матч ВСЕХ разрешённых адресов запроса
  (ClientIp public/socket/internal + REMOTE_ADDR, v4+v6, bare IP = точный
  матч) против непросроченных строк; один индексированный запрос на запрос
  + per-instance кэш; **fail-open** при ошибках БД (до миграций); kill switch
  `IP_BLOCKS_ENFORCE` (default true); регистрация в контейнере **non-shared**
  (кэш не протухает между запросами одного процесса);
* Kernel: гейт сразу после maintenanceGate, **до** sessions/visit-capture —
  заблокированный адрес не получает сессию, визит, rate-limit-бакет;
  ответ — голый 403 (JSON-конверт `ip_blocked` для wantsJson, минимальная
  тёмная HTML-страница иначе, детали правила не раскрываются); структурированный
  лог `security.ip_block.enforced`;
* тесты: `IpBlockGuardTest` (10: exact/cidr v4/v6/socket/internal/expired/
  future/kill-switch/кэш/invalid) + `IpBlockEnforcementTest` (7: html 403,
  json 403, нет визита и Set-Cookie, незаблокированные не страдают, expired,
  kill switch, XFF через trusted proxy);
* live-дрель: 200 → блок 127.0.0.1/32 → **403** (html + `{"ok":false,"error":
  {"code":"ip_blocked",...}}` на /api) → снял блок → 200; в логе 24 записи
  `security.ip_block.enforced`;
* `.env.example`: IP_BLOCKS_ENFORCE задокументирован; B-004 (устаревший текст
  admin.ip_blocks_hint) — в §8.

**Гейты:** тесты **454/2988/0** (+20) · lint **217/0** (+3 файла). Коммит
`9534185`. Найден и устранён подводный камень тест-раннера: контейнер
мемоизирует shared-фабрики между тест-классами — guard инжектится в тестах
детерминированно через `set()`.

**Отчёт об остатке (rule 11, новый scope):** P12 ЗАКРЫТ (заморожен 42/66).
Осталось: P14 — 4 пункта (rate-limit tuning, N+1 sweep, slow-query pass,
pentest-дрели; enforcement ✅), P13 design pass, P15 beta loop, P16
Docker/CI/release + отложенные эмиттеры. Общая готовность ≈ (11 + 1 + 0.2)/16
фаз ≈ **76%**, **осталось ≈ 24%** плана.

**Следующий шаг (§9):** P14(a) rate-limit tuning → (b) N+1 sweep → (c)
slow-query pass → (d) pentest-дрели; затем P13 → P15 → P16.

## 2026-10-18 — P14(a) rate-limit tuning + P14(b) N+1 sweep ✅✅

**(a) Rate-limit tuning (`92f8ccd`).** Аудит показал: бакеты RATE_LIMIT_*
были **только документацией** (rateLimitSummary в /api/v1/status), реально
enforced были лишь auth/tracker/intel/swarm. Теперь:

* **RATE_LIMIT_DEFAULT** — глобальный per-IP гейт в Kernel (apex+swarm зоны;
  **tenant-зона исключена** — у неё свои более строгие бакеты, бережём
  редирект-бюджет 1-3 мс; /health исключён; kill switch
  `RATE_LIMIT_DEFAULT_ENFORCE`, 429 + Retry-After);
* **RATE_LIMIT_API** — публичные data-эндпоинты PublicApiController
  (subdomain/links/stats/visits; docs/health/status/languages освобождены);
* **RATE_LIMIT_REDIRECT** — в TenantController::resolve **до** любых обращений
  к БД (шторм 404-зондов тоже лимитируется);
* проверено существующее: auth IP-throttle + lockout AuthService (account И ip,
  blockedFor), tracker/consent, intel-inbox+auth-fail, swarm-бакеты — оставлены;
* +8 интеграционных тестов (RateLimitEnforcementTest): 429+Retry-After, per-IP,
  health/tenant/docs exempt, kill switch. **462/3009/0** на тот момент;
* live-дрели: apex 5×200→429; redirect (временный tenant rldrill + link go,
  лимит 2:60) **302,302,429,429**; откат .env и строк БД полный;
* Config default + .env.example задокументированы.

**(b) N+1 sweep (`e36873a`).** §9点名 «agentName batching» подтвердился:
`SwarmService::agentName()` = SELECT на каждую строку проекции (messages/
sections/tasks/knowledge/threads/comments — списки до 100 строк). Фикс:
* memo-кэш на инстанс (+ negative cache) — повторные авторы бесплатны;
* `primeAgentNames(ids)` — один IN-запрос на список; подключён в 7 точек:
  sections, messageList, messageSearch, knowledge (plain+search), threadList,
  taskList (assignee+created_by), featureShow-comments;
* VisitService: click_limit dedupe-запрос вынесен из per-user цикла вебхуков
  (не зависит от userId — был N×users запросов);
* аудит «подозрительных» циклов (эвристический скан по всему src/): queue-дрейны
  (AbuseReport/IpIntel/MaintenanceWorker), Seeder, A/B variant sync, recovery-
  codes, ReportsService UTM (фикс. 5 запросов), Notifier templates (≤2) —
  НЕ N+1, оставлены как есть (зафиксировано в коммите);
* +5 тестов (SwarmAgentNameCacheTest: memo/negative-cache/noop/messageList).

**Гейты:** тесты **467/3030/0** · lint **219/0**. Побочное: найдена и
починена особенность тест-раннера (мемоизация shared-фабрик контейнера —
RateLimiter/IpBlockGuard в тестах инжектятся через set()).

**Отчёт об остатке (rule 11):** P12 закрыт (42/66 frozen). P14: 3/5 ✅
(enforcement, rate-limits, N+1); осталось **(c) slow-query pass** (EXPLAIN
QUERY PLAN по visits-rollups, индексы/преагрегаты) и **(d) pentest-дрели**
(_sec_admin_check.py, _rbac_check.py + новый ip_blocks/rate-limit drill-скрипт).
Далее P13 design pass → P15 beta loop → P16 Docker/CI/release (+ эмиттеры
link.target_down, agent.task). Готовность ≈ (11+1+0.6)/16 ≈ **79%**,
**осталось ≈ 21%**.

**Следующий шаг (§9):** P14(c) slow-query pass.

## 2026-10-18 — P14(c)+(d): RollupBuilder реализован, дрели зелёные — **P14 ЗАВЕРШЁН** ✅

**(c) Slow-query pass (`abd9b37`).** Главная находка: `RollupBuilder::rebuildRecent()`
был **стабом** (return 0) — worker каждую минуту вызывал пустоту, а все отчёты
сканировали `visits` напрямую. Реализован: day+hour «total»-роллапы на
subdomain+global из сырых визитов (visits/uniques/bots/llm/suspicious/new/
returning/clicks/avg_time/avg_scroll), идемпотентный upsert на ux_rollup,
**лечит частичные строки** anonymous-бампера (тест: visits=99→1). EXPLAIN
QUERY PLAN на сиде 20k визитов/30 дней: все горячие запросы отчётов —
SEARCH через ix_visits_sub_time (без SCAN), 4-25 мс; recent-списки сортируют
по visited_at (покрыт) — **новые индексы не нужны**. +5 тестов.

**(d) Pentest-дрели (`256baaf`).** Новый повторяемый `scripts/_p14_edge_check.py`
— **12/12**: redirect/api/global бакеты (семантика «limit N → 429 на N+1-м»),
Retry-After, health exempt, ip_blocks edge 403 html+json (включая exempt-
эндпоинты — порядок гейтов), unblock→200, kill switch, полная уборка строк
БД и .env. Важное открытие: **Config::env() отдаёт приоритет .env над
реальными env-переменными** (докблок говорит обратное) — дрель поэтому
временно переписывает .env с гарантированным восстановлением;Finding зафиксирован
как **B-005**. Прогон `_sec_admin_check.py` (security desk/ip-blocks/geoip/
flags/plans) + `_rbac_check.py` (editor/viewer/invites/shared views) на живом
сервере: **ALL PASSED**. Заодно: dev-БД засеяна демо-данными (`bin/iya seed`,
user_id=1, subdomain_id=3, 12 ссылок) — понадобилась дрелям.

**Гейты:** 472/3055/0 · lint 220/0 · P14 edge drill 12/12 · sec-admin ✓ · rbac ✓.

**Отчёт об остатке (rule 11):** P12 🔒 frozen 42/66 · **P14 ✅ 5/5**.
Осталось: **P13** design pass (dark theme, RTL-полировка ar/fa/he, mobile,
empty states, focus rings) → **P15** beta loop (demo-journey, feedback, fix
list) → **P16** Docker/CI/release (+ отложенные эмиттеры link.target_down,
agent.task). Готовность ≈ (11+1+1)/16 = **81%**, **осталось ≈ 19%** плана.

**Следующий шаг (§9):** P13 design pass.

## 2026-10-18 — P13 design pass ✅ (аудит + точечная полировка)

**Аудит (все четыре пункта §9 — инфраструктура оказалась зрелой):**
* **dark theme**: токены `:root[data-theme='dark']` (tokens.css:229), no-flash
  `themeScript()` в <head>, `theme-color`/`color-scheme` мета; хардкод цветов
  в шаблонах — только `#fff` под QR (3 места, намеренно: контраст для
  сканирования);
* **RTL**: components.css — **0 физических свойств vs 169 логических**
  (margin-inline и т.п.), rtl.css (165 строк точечных фиксов) подключается
  условно через `View::baseStyles()`/`isRtl()`; live-проверка: `?lang=he` →
  `dir="rtl"` + rtl.css в head; `?lang=en` → rtl.css отсутствует;
* **mobile**: viewport-мета ✓, 9+ @media-брейкпоинтов (560-1024px),
  `.table-wrap{overflow-x:auto}` в 29 шаблонах;
* **focus rings**: глобальный `:focus-visible` (base.css:102) + компонентные
  (btn/choice/switch/tooltip/skip-link).

**Полировка (найденный пробел):** empty-states. Компонент `.empty`
(icon/title/text) существовал, но использовался ровно в 1 шаблоне; списки
links (без субдомена), views, collections рендерили ad-hoc блоки с инлайн-
стилями или `.table__empty`. Конвертированы в `.empty` (globe/eye/folder
иконки из Icon-спрайта).

**Закрепление:** `HttpSmokeTest::testP13DesignInvariants` — viewport,
color-scheme, data-theme, rtl.css только для RTL, :focus-visible в base.css,
`.empty` в трёх шаблонах. Тесты **473/3066/0** · lint **220/0**.

**Отчёт об остатке (rule 11):** P12 🔒 42/66 · P13 ✅ · P14 ✅ 5/5.
Осталось: **P15 beta loop** (demo-journey, feedback capture, fix list) и
**P16 Docker/CI/release** (Dockerfile, CI, semver, release notes + эмиттеры
link.target_down, agent.task). Готовность ≈ 14/16 фаз = **87.5%**,
**осталось ≈ 12.5%** плана.

**Следующий шаг (§9):** P15 beta loop.

## 2026-10-18 — P15 beta loop ✅ (journey 13/13, найден и починен бета-блокер)

**`scripts/_beta_journey.py`** — золотой путь внешнего тестировщика одним
скриптом (13 проверок, live HTTP): публичные страницы → самостоятельная
регистрация (CSRF `_token` + PRG) → онбординг-субдомен → redirect-ссылка
через форму → tenant 302 → `worker --once` → визит в /dashboard/visits →
notifications → swarm-агент одним GET → секция+сообщение → feature proposal
→ публичный список → админ-триаж в /admin/proposals. Детерминирован:
чистит rate_limits и failed_login_count/locked_until на старте, удаляет все
`bjk`-строки в конце; **13/13 два прогона подряд**.

**Реальный бета-блокер, который journey нашёл и который починен:**
`DashboardController::onboarding` был 501-стабом, при том что редирект после
регистрации (Location: /dashboard/onboarding) И CTA empty-state списка
ссылок указывают именно туда — новый пользователь упирался в «not
implemented». Фикс: делегирование в `AnalyticsTrait::subdomain()` (рендерит
dashboard/onboarding когда субдомена нет, иначе настройки).

**Находки (задокументированы в BETA.md/коммите):** чекбокс `is_enabled`
браузер отправляет сам, API-стиль POST должен явно; лок-бакет аккаунта
`rl:<acct>:account` не ловится фильтром '%auth%'; логин-поле `identifier`.

**`docs/BETA.md`**: окружение (migrate/seed/serve/worker, demo-учётка),
золотой путь, каналы фидбека (mailto из errors.report + футер, /status,
swarm feature proposals (агентский API) + публичный roadmap, админ-триаж),
**triaged fix list** (account-стабы 501, LegacyApi-стабы, B-004/B-005,
rollup-rewire follow-up) и полный набор дрелей перед бета-сборкой.

**Гейты:** 473/3066/0 · lint 220/0 · journey 13/13.

**Отчёт об остатке (rule 11):** P12 🔒 42/66 · P13 ✅ · P14 ✅ · P15 ✅.
Осталась **одна фаза: P16 Docker/CI/release** (Dockerfile, CI-воркфлоу
lint+tests+smoke+drills, semver-политика, release notes из CHANGELOG,
отложенные эмиттеры link.target_down/agent.task). Готовность ≈ 15/16 =
**94%**, **осталось ≈ 6%** плана.

**Следующий шаг (§9):** P16 — финальная фаза.

## 2026-10-18 — P16 Docker/CI/release + эмиттеры ✅ — **ВСЕ ФАЗЫ P1–P16 ЗАКРЫТЫ** 🏁

**Отложенные эмиттеры P10 (последний функциональный долг):**
* **`link.target_down`**: `src/Domain/Link/TargetChecker.php` — worker-таск
  `target_checks` (бюджет `TARGET_CHECK_BUDGET`, dev=0): выбирает least-
  recently-checked активные redirect-ссылки, SSRF-guard → HEAD, fallback
  ranged GET (5s), пишет `target_status` (ok|down|blocked) +
  `target_checked_at`, **эмитит вебхук только на переходе ok→down** (флап не
  спамит). Транспорт — инжектируемый seam: 5 unit-тестов без сети
  (transition-once, recovery, SSRF-blocked-never-probed, disabled/non-
  redirect skip, seam).
* **`agent.task`**: SwarmService (опциональный WebhookService, Bootstrap
  wired) — created/claimed/status_changed(+from/to)/handoff, fan-out всем
  юзерам с включёнными вебхуками (emit() сам фильтрует подписки), никогда не
  ломает swarm-вызов (try/catch + warning). 3 теста (lifecycle-последо-
  вательность, отписка не получает, без WebhookService всё работает).

**Docker/CI/release:**
* `Dockerfile`: php:8.2-cli-alpine + pdo_sqlite/sqlite3/mbstring/intl/zip/
  curl/opcache (build-deps virtual), migrate-on-boot, healthcheck
  /api/v1/health, VOLUME /app/var; `.dockerignore`;
* `.github/workflows/ci.yml`: doctor → migrate → lint → tests →
  build_i18n --check; отдельная docker-job (build + smoke внутри образа).
  Первая реальная сборка образа — в CI (в песочнице docker недоступен);
* `php bin/iya release:notes [version]` — экстрактор секции CHANGELOG
  (проверен на Unreleased); `docs/RELEASE.md` — semver-таблица (MAJOR/
  MINOR/PATCH + отдельный API_VERSION), ритуал релиза (гейты → freeze
  CHANGELOG → bump Version.php → notes → tag → sync/pack → docker),
  changelog-дисциплина;
* CHANGELOG [Unreleased]: сведены все изменения P13–P16 (Added/Fixed/i18n).

**Ловушка Config (задокументирована):** env-значения игнорируются для ключей
вне `Config::defaults()` — TARGET_CHECK_BUDGET добавлен в defaults (worker
live-проверен: checked=0 при бюджете 0). Родственник B-005.

**Гейты:** 483/3109/0 (+10) · lint 223/0 · worker live ✓ · journey 13/13 ✓.

**Отчёт об остатке (rule 11, ФИНАЛЬНЫЙ):** P1–P16 — **все фазы закрыты**.
Осталось ≈ **0% запланированного**; вне плана — пост-релизные follow-ups:
B-004, B-005 (+defaults-ловушка), account/*-стабы, rollup read-rewire,
первая docker-сборка в CI, 24 замороженные локали (опция).

**Следующий шаг (§9):** план завершён — ждать новых указаний пользователя
или брать follow-ups из списка выше.

## 2026-10-18 (evening, post-P16) — SUCCESSOR KIT: dev bundle + guides + env recipe

По запросу пользователя собран переносимый комплект для агента-преемника:
- **`"$ARENA_WORKSPACE"/IYA-DEV-BUNDLE.tar.gz`** (13M, sha256 в sidecar-файле): полный
  репо С .git-историей (376 коммитов), backup/i18n-слой, 4 гайда, P12-манифест.
  Зеркало: `/mnt/oss/iya-agent-bundle/`. Валидация: git из архива читается,
  HEAD совпадает, i18n-слой на месте.
- **`docs/RESILIENCE-PLAYBOOK.md`** (главный): аксиомы среды, 6 слоёв защиты,
  ритуал чекпоинта, decision-tree восстановления (4 ветки), **24 реальные
  грабли с лекарствами** (потери состояния, пути/remap, kill-suicide, контейнер-
  мемоизация, Config-ловушки, тест-раннер, i18n-конвейер, бэкапы), методы
  работы, «потеряно всё»-быстрый старт. Копия в корне workspace.
- **`docs/AGENT-GUIDE.md`**: карта workspace, развёртывание, порядок доработки,
  статус/направления, чего нет в песочнице и чем заменять. Копия в корне.
- **`docs/ENVIRONMENT.md` + `scripts/setup-env.sh`**: точный рецепт среды
  (PHP 8.2.34 + расширения, идемпотентный бутстрап, гейты здоровья).

Попутные находки и фиксы:
1. **i18n-бэкап-слой усох** (262 файла при восстановлении → 74; durable-слои
   тоже могут меняться извне — на чекпоинтах сверять целостность слоёв!).
   Дозеркалил недостающие `_platform.{ru,uk,be,de,es}.json` + ru/en generated
   в backup/i18n и /mnt/oss/iya-i18n → слои снова идентичны (41 _platform +
   _base.en = 42 локали).
2. **`.env`/`.env.example` несли id,ms в SUPPORTED_LOCALES** без каталогов
   (остаток от пре-фриза; 44 кода вместо 42; лишние падали в en-фолбэк).
   Вычищено до ровно 42; build --check: full coverage 42/66.
3. Подтверждение грабли №6 из playbook: sandbox-remap сломал литеральные пути
   внутри `php -r`-однострочника (`"$ARENA_WORKSPACE"` в коде) — инлайн-скрипты
   писать относительными путями или через python-heredoc.

Гейты: **483 tests / 3109 assertions / 0 failures** · lint **223 files / 0** ·
i18n **42/66**. Слои: git + L1 P12 + L2 versions + L3 oss-backups + L4 i18n +
L6 bundle (новый) — все синхронизированы; локальный бюджет < 128M.

## 2026-10-19 — P17 PLAN: sandboxed agent contribution pipeline (ADR-019) + комплект преемника пополнен

**Контекст:** пользователь принёс бриф-идею «captcha для агентов → вклад в сайт»
и поручил: проработать, написать подробный план реализации, положить в корень
Workspace план + AGENTS.md + все разработанные нами файлы, нужные агенту-
реализатору (включая напутствия по безопасной работе в этой среде).

**Микро-сбой среды в начале сессии (третий по счёту, отработали по playbook):**
`"$ARENA_WORKSPACE"/iya-platform` на месте, но `.git` отсутствует и php не установлен.
Восстановление: sha256 бандла OK → `.git` извлечён из `IYA-DEV-BUNDLE.tar.gz`
(378 коммитов, HEAD `38107b7`, дерево чистое) → `setup-env.sh` (PHP 8.2.34 +
7 расширений) → гейты сверены с §0: **483/3109/0 · lint 223/0**. Подтверждение
слоя L6: бандл спас .git в третий раз.

**Сделано:**
1. **`docs/AGENT-CONTRIB-PLAN.md`** (973 строки) — полный план P17, фазы C0–C8:
   карта переиспользования платформы (swarm-идентичность, RateLimiter, IpBlockGuard,
   MaintenanceWorker-бюджеты, sections/knowledge для фидбека — без новых таблиц,
   llms.txt/agents.json/openapi, admin RBAC, git+CI); схема БД `009_agent_contrib.sql`
   (9 таблиц: files/challenges/candidates/runs/scores/review_tasks/reviews/promotions/
   reputation); GET-first API `/api/v1/contrib/*` (envelope, коды ошибок, rate-limits
   1/10мин+5/сутки challenge, идемпотентность client_submission_id); allowlist из
   РЕАЛЬНЫХ файлов репо + жёсткий denylist + SourceSanitizer + инвариант «нет
   контрактных тестов — нет выдачи»; 8 типов задач; runner L0 static / L1 hardened
   subprocess (proc_open+env -i+ulimit+open_basedir+disable_functions; в песочнице
   docker нет — L1 для dev) / L2 docker (--network none; промоция в prod ТОЛЬКО L2);
   11 гейтов (блокирующие: syntax/secrets/dangerous/api_compat/unit_tests...);
   ScoreEngine детерминированный stdlib (критерий «≥50% размера» из брифа ОТВЕРГНУТ
   осознанно — размер вес 5/100); pairwise reviewer-контур со строгим CSV-парсером
   (RFC4180, 10 правил, анти-коллюзия, prompt-injection-защита explanation);
   промоция git-ветка → полный CI → beta-окно метрик (5xx/p95/JS-errors/alerts/
   конверсия) → prod с human gate (CONTRIB_AUTO_PROMOTE=false); kill switches
   3 уровней + `contrib:rollback` CLI < 60s; репутация/страйки/баны; 10 discovery-
   поверхностей; threat-model T1–T13; 17 CONTRIB_*-ключей (все в defaults() — B-005);
   тест-конвенции проекта (кастомный раннер, seam-паттерн, drills); DoD (§22).
   Оценки: 11–15 рабочих блоков.
2. **`AGENTS.md`** (repo root + корень workspace) — входная точка реализатора:
   состав комплекта, быстрый старт с ожидаемыми гейтами, задание, 10 напутствий
   по безопасной работе (выжимка RESILIENCE-PLAYBOOK), DoD-ссылка.
3. **`scripts/pack_bundle.sh`** — сборка бандла L6 стала воспроизводимой
   (раньше ad-hoc): гейты → staging (репо+.git без runtime-мусора, backup/i18n,
   гайды, AGENTS.md, план, авто-манифест с HEAD/commit-count) → tar+sha256 →
   `--mirror` в /mnt/oss/iya-agent-bundle/.
4. **REWORK-STATE.md**: ADR-019; §3 — строка P17 planned + синхронизированы
   устаревшие статусы P11–P15 (таблица отставала от §9); §0 — Last checkpoint /
   Current phase (P17 PLANNED, реализация не начата); §9 — NEXT STEP переписан
   на P17/C0 + формула прогресса P17 для правила 11 (done=(11+P12+P13..P16+C/9)/17).
5. **Комплект в корне workspace** (запрос пользователя): AGENTS.md,
   AGENT-CONTRIB-PLAN.md, REWORK-STATE.md, WORKLOG.md, CHANGELOG.md, setup-env.sh
   + уже лежавшие RESILIENCE-PLAYBOOK.md, AGENT-GUIDE.md, ENVIRONMENT.md,
   P12-MANIFEST.md, IYA-DEV-BUNDLE.tar.gz(+sha256), P12.tar.gz. Бандл пересобран
   (включает план и AGENTS.md), зеркала в /mnt/oss обновлены.

**Гейты:** 483 tests / 3109 assertions / 0 failures · lint 223 files / 0 failures
(документационный чекпоинт — код P17 не писался, регрессий нет).

**Правило 11 — остаток:** P12: 24/66 локалей заморожены (ADR-018, 0% в работе).
P17: 0/9 C-фаз (план 100%, реализация 0%). Общий roadmap: done = (11 + 42/66 +
P13..P16 4/4 + 0/9·P17)... по формуле §9: фаз всего 17 → закрыто 15 + P12(0.64)
+ P17(0) = 15.64/17 ≈ 92% · **осталось ≈ 8%** (P17 реализация ~11–15 блоков;
P12 — только если пользователь разморозит).

## 2026-10-19 (later) — P18 PLAN: инвентаризация потерь + `/dashboard/settings` (ADR-020)

**Контекст:** пользователь указал, что раздел `/dashboard/settings` из
первоначального ТЗ не пережил одно из падений, и поручил провести инвентаризацию
(не потерялось ли ещё что-то), добавить раздел в план и собрать собрату
единый tar.gz-комплект для разработки.

**Инвентаризация.** Написан read-only аудит `scripts/_inventory_audit.php`
(бутит роутеры apex/tenant/swarm через `Routes::register`, читает приватный
`Router::$routes` рефлексией, парсит Routes.php в карту `VERB path → Ctl::method`,
детектит `notImplemented` в телах, сканирует шаблоны на мёртвые ссылки,
render-цели и ассеты). Итог: **525 роутов, 23 из них 501-стабы, 0 отсутствующих
шаблонов, 0 битых ассетов**.

Утрачено (B-006, high):
- `/dashboard/settings` — пункт nav в `layouts/dashboard.php:23` ведёт в 404
  (маршрут отсутствует в apex-зоне);
- 20 хендлеров `AccountController`: profile GET/POST, security, password,
  2FA×4, sessions×3, preferences×2, privacy×2, api-tokens×3, export,
  delete GET/POST;
- 3 хендлера `DashboardController`: subdomain appearance / stats-policy / delete;
- шаблоны всех вышеперечисленных страниц (в `Templates/account/` выжили только
  alerts, notifications, team, webhooks);
- домен API-токенов (таблица `api_tokens` есть, сервиса нет);
- достоверность `docs/BETA.md` §4 (утверждал, что sessions/API tokens/
  preferences/export/delete работают — исправлено в этом коммите).

Уцелело: схема БД (`users` со всеми колонками настроек, `sessions`,
`remember_tokens`, `auth_tokens`, `totp_recovery_codes`, `api_tokens`,
`http_sessions`), `AuthService` (34 публичных метода: 2FA-анролмент/подтверждение/
отключение, recovery-коды, сессии list/revoke/revoke-all, updateProfile,
changeEmail+confirm, changePassword, deleteAccount, exportUserData),
`Security/Totp.php`, `Security/Csrf.php` + глобальная проверка в Kernel,
`BaseController`-хелперы, живые конвенционные примеры (alerts/webhooks).
Проверено отдельно: ссылки `/theme/{v}`, `/lang/{code}`, `/qr/{path}`,
`/invite/{token}`, `/analytics/*` живы (аудит initially пометил их из-за
конкатенации в шаблонах — подтверждено диспетчем, ложные срабатывания).

**Результаты раунда.**
1. **`docs/SETTINGS-PLAN.md`** — план P18 (S0–S4): карта reuse UI→AuthService
   (21 строка «стаб → готовый доменный вызов»), ADR-решения D1–D7 (канонический
   URL `/dashboard/settings/*` при сохранении `/account/*`-алиасов без редиректов;
   SSR-hub без табов; новый `ApiTokenService`; subdomain-трио отдельной фазой S4;
   i18n только en+ru с en-fallback для 42 локалей; тесты на харнессе
   HttpSmokeTest с реальной сессией; фикс BETA.md), готовый блок роутов для
   Routes.php, спецификации 7 шаблонов, ~70 i18n-ключей, план тестов,
   чеклист безопасности (8 пунктов), DoD.
2. **`scripts/_inventory_audit.php`** — воспроизводимый аудит (запускать после
   каждой фазы P18: цель — 0 стабов в account-группе).
3. **REWORK-STATE.md**: ADR-020; §3 — строка P18 planned (приоритет выше P17);
   §8 — баг B-006 (high, planned P18); §0 — Last checkpoint / Current phase
   (P18 PLANNED); §9 — NEXT STEP = P18/S0, формула правила 11 → /18
   (done=(11+P12+P13..P16+S/5+C/9)/18).
4. **AGENTS.md** (репо + корень workspace): задание переупорядочено — сначала
   P18 (S0→S4), затем P17 (C0→C8); комплект пополнен SETTINGS-PLAN.md и
   аудит-скриптом; ожидаемое число lint-файлов 223→224; §5 ДоГ переписан.
5. **docs/BETA.md** §4 — фикс устаревшего описания (L6).
6. **Комплект собрату** `"$ARENA_WORKSPACE"/IYA-HANDOFF-KIT.tar.gz` (+`.sha256`):
   всё для старта разработки в одном файле — см. его `MANIFEST.txt`.

**Гейты:** 483 tests / 3109 assertions / 0 failures · lint 224 files / 0 failures ·
i18n coverage 42/66 (документационно-инструментальный чекпоинт — код P18 не
писался, регрессий нет).

**Правило 11 — остаток:** P12: 24/66 локалей заморожены (ADR-018, 0% в работе).
P18: 0/5 S-фаз (план 100%, реализация 0%). P17: 0/9 C-фаз (план 100%, реализация 0%).
По формуле §9: фаз всего 18 → закрыто 15 + P12(0.64) = 15.64/18 ≈ 87% ·
**осталось ≈ 13%** (P18 ~4–6 блоков, P17 ~11–15 блоков; P12 — только если
пользователь разморозит).

**Попутная находка и фикс (B-007):** `php bin/iya migrate --reset` дропал
таблицы в порядке sqlite_master при `PRAGMA foreign_keys=ON` — дроп `users`
раньше детей ронял reset («no such table: main.users») и оставлял dev-БД
полусломанной. Исправлено в `bin/iya`: сначала triggers/views, затем
`PRAGMA foreign_keys=OFF` → дроп таблиц → `ON`. Проверено: полная пересборка
dev-БД (8 миграций + seed) и симуляция рваной БД (94 объекта дропнулись,
re-migrate+seed прошли). Тестовые БД не затрагивались — гейты зелёные
(483/3109/0, lint 224/0).

**Гейты (после фикса B-007):** 483 tests / 3109 assertions / 0 failures ·
lint 224 files / 0 failures · i18n 42/66 · dev-БД: migrate+seed OK.
