# REWORK-STATE.md — iya.ru Platform Rework

> **Single source of truth for any agent continuing this project.**
> Primary copy: `/home/user/iya-platform/REWORK-STATE.md` (persisted in workspace snapshots).
> Timeline: `WORKLOG.md` (append-only journal — read it FIRST after a context loss).
> Mirrors: `/workspace/{work,backup}` are **volatile scratch** (they do NOT survive a
> sandbox reset). The durable backup is `/home/user/backup/versions/*.tar.gz` (written
> by `scripts/sync.sh`, rotated at 20, never overwritten).
> Rule: never destroy history. Update this file **before** and **after** every work block.

---

## 0. Snapshot (read this first)

| Item | Value |
|---|---|
| Project | iya.ru — multi-tenant personal-link platform + visit analytics + LLM agent-swarm API |
| Repo root (primary) | `/home/user/iya-platform` |
| Repo root (work mirror) | `/workspace/work/iya-platform` (volatile!) |
| Repo root (backup mirror) | `/workspace/backup/iya-platform` (volatile!) |
| Durable backups | `/home/user/backup/versions/iya-platform_<stamp>_<commit>.tar.gz` |
| Last checkpoint | **P18 PLAN 2026-10-19 — инвентаризация потерь + принят план P18 (ADR-020)**: `scripts/_inventory_audit.php` (новый, read-only: 525 роутов трёх зон, стаб-детект, мёртвые ссылки, render-цели, ассеты) подтвердил утрату веб-слоя настроек при раннем падении — `/dashboard/settings` 404, 23 роута-501 (20 AccountController + 3 subdomain), шаблоны account/profile/security/sessions/preferences/privacy/api-tokens утрачены, ApiTokenService нет, BETA.md §4 устарел; домен+БД уцелели (AuthService, Totp, таблицы). Написан `docs/SETTINGS-PLAN.md` (255+ строк: карта reuse UI→AuthService, роуты, 7 шаблонов, ~70 i18n-ключей, тесты, чеклист безопасности, фазы S0–S4, DoD). P18 поставлен ПЕРЕД P17. Гейты: 483/3109/0 · lint 224/0 · i18n 42/66. Предыдущий: P17 PLAN 2026-10-19 (ADR-019, `docs/AGENT-CONTRIB-PLAN.md`) |
| Current phase | **P18 PLANNED — реализация НЕ начата** (приоритет выше P17, ADR-020). P1–P16 закрыты, платформа релизопригодна: 483/3109/0 · lint 224/0 · journey 13/13 · edge drill 12/12 · i18n 42/66 (frozen, ADR-018). Следующая работа: **P18 фаза S0** по `docs/SETTINGS-PLAN.md` (роуты `/dashboard/settings` + hub-страница), затем S1–S4, затем P17 (C0→C8). Входная точка — `AGENTS.md`. Пост-релизные follow-ups (не блокеры): B-004, B-005, rollup read-rewire, первая сборка Docker-образа валидируется в CI, 24 замороженные локали (§9 P12). Account/*-стабы ПЕРЕВЕДЕНЫ из follow-ups в активную фазу P18 |
| Lost context? | **Start at `docs/AGENT-GUIDE.md` + `docs/RESILIENCE-PLAYBOOK.md`, then §9 (NEXT STEP) and the WORKLOG.md tail.** Recovery sources, in priority order: `/home/user/IYA-DEV-BUNDLE.tar.gz` (полный комплект: репо+.git+гайды+i18n-слой) → `/home/user/P12.tar.gz` (freshest, repo + git history + manifest) → `/home/user/backup/versions/*.tar.gz` (per-commit history) → `/mnt/oss/iya-platform-backups/*.tar.gz` + `/mnt/oss/iya-agent-bundle/` (full per-commit history + P12/P12_PRE/bundle mirrors) → **external copy (user-provided 2026-10-18): `https://iya.ru/qwen_access/iya-platform/` + `https://iya.ru/qwen_access/P12-MANIFEST.md`** → `/mnt/oss/*.tar.gz` (emergency tail only). i18n: never edit `web/i18n/<locale>.json` by hand — edit `_base.en.json` / `_platform.<locale>.json` and run `php scripts/build_i18n.php` (see §5.4). |
| Runtime | **PHP 8.2 stdlib-only** (composer deps only in `tests/`), SQLite WAL, buildless ES-module frontend |
| Tests | `php tests/run.php` (custom runner) · lint: `php bin/iya lint` · smoke: `scripts/smoke_http.py`, `scripts/smoke_swarm.py <port>` |
| Run (dev) | `php bin/iya serve --host=127.0.0.1 --port=8080` (equals-form flags; worker: `php bin/iya worker`) |
| Author | Mikhail Deynekin — icons@Deynekin.com — https://Deynekin.com — https://github.com/paulmann |
| Version | `app/version.py` (semver) |
| License | MIT (`LICENSE`) |

### Hard constraints of the sandbox (do not fight them)
1. Installed OS/pip packages **do not survive** between snapshots → the app MUST run on a bare
   `python3` with no `pip install`. Optional exporters (`openpyxl`, `fpdf2`, `xlsxwriter`) are used
   only when importable, with pure-stdlib fallbacks (`xlsx_writer.py`, `pdf_writer.py`).
2. `write_file`-style tools can only write under `/home/user` → **primary repo lives there**;
   `/workspace/{work,backup}` are mirrors created with `bash` (`cp -a`, `tar`).
3. No `curl`, no `rsync`. Use `python3 -m urllib`/`http.client`, `tar`, `cp -a`.
4. **No CDN anywhere** in the frontend: fonts, icons, CSS, JS are local files only.

---

## 1. Architecture decisions (ADR log)

| # | Decision | Rationale |
|---|---|---|
| ADR-001 | Pure-stdlib Python backend (own HTTP core on `ThreadingHTTPServer`, own router, own template engine) instead of FastAPI/Django | Runs anywhere with zero install; survives sandbox resets; full control over headers/proxy trust chain (critical for the external-IP fix) |
| ADR-002 | SQLite WAL, one connection per thread, versioned SQL migrations, storage isolated behind `app/infrastructure/db.py` | Zero-ops, fast for target load; driver abstraction keeps a Postgres path open (see `docs/ARCHITECTURE.md`) |
| ADR-003 | Buildless frontend: design tokens (`web/css/tokens.css`) + shadcn/ui-style components (`components.css`) + Tailwind-compatible utility subset (`utilities.css`), vanilla ES modules, hand-rolled SVG chart library | "No CDN, local assets, shadcn look" without an npm build step |
| ADR-004 | Server-rendered HTML for public pages; JSON + SSE for the dashboard (no WebSocket) | Fast TTFB / CWV, works behind any proxy, stdlib-only |
| ADR-005 | Agent-swarm API is **GET-first**; token via `?token=` or `Authorization: Bearer`; only SHA-256 token hashes stored; registration idempotent by `agent_id` | Spec requirement; hash-only storage limits DB-leak damage |
| ADR-006 | Password hashing: **scrypt** in a PHC-like string, behind an Argon2id-ready interface | `hashlib.scrypt` is stdlib & memory-hard; `hashlib.argon2` doesn't exist in 3.11 |
| ADR-007 | i18n: JSON catalogs `web/i18n/<lang>.json`, 30 languages, `en` = source of truth, missing keys fall back to `en` | New language = new file, no code change |
| ADR-008 | Analytics write path: request → in-process queue → batched insert (ingest worker) → rollup worker builds aggregate tables | Redirect latency stays ~1-3 ms; enables retention/partitioning |
| ADR-009 | Privacy modes `minimal/standard/extended/anonymous` are **server-side field allow-lists**, not UI-only toggles | A client cannot force extra collection |
| ADR-010 | Locale priority: `?lang=` → user preference → cookie → `/xx/` sub-path → GeoIP hint → `Accept-Language` → `en`; hreflang emitted | SEO-friendly and satisfies the explicit `?lang=ru` requirement |
| ADR-011 | Sections hierarchy uses materialized path (`/1/4/9/`) + `depth` column, enforced ≤ 6 in domain **and** by a DB CHECK | Cheap subtree queries + hard guarantee |
| ADR-012 | Full-text search via SQLite FTS5 (`messages_fts`, `knowledge_fts`) with `LIKE` fallback if FTS5 is unavailable | Portable |
| ADR-013 | Abuse reporting (P8.6): outbound AbuseIPDB reports (attacks flagged by ThreatDetection + spam ingested from the mail server) flow through a durable `abuse_report_queue` drained by the maintenance worker with its own per-minute/daily budgets, a dedupe window and a sent-log (`abuse_report_log`). Inbound spam intake: **primary** = authenticated HTTP endpoint `POST /api/v1/intel/spam-report` (shared secret via `X-Intel-Secret`/Bearer, JSON or form body); **optional fallback** = the worker polls a MySQL drop-box table every minute when `SPAM_INBOX_DSN` is set. Endpoint-first because the platform DB is SQLite (a MySQL poll would add a runtime pdo_mysql dependency) and intake is real-time; the poller stays for firewalled setups where the mail server can only write to the DB. Both intake paths land in the same `spam_reports` table (idempotent by `UNIQUE(ip, evidence_hash)`). Gated by admin checkboxes `ipintel.report_attacks` / `ipintel.report_spam` / `ipintel.inbox_enabled`; AbuseIPDB provider limit "same IP once per 15 min" respected via the dedupe window | User request 2026-10-17; reuses the proven ip_intel_queue worker pattern; operator decides what leaves the server |
| ADR-014 | Durable duplication for P12 i18n: after EVERY finished locale, `scripts/backup_i18n.sh <code>` copies `_base.en.json` + `_platform.<code>.json` + generated `<code>.json` to `<workspace>/backup/i18n/` (snapshot-durable) and `/mnt/oss/iya-i18n/` (best effort); whenever the full-coverage count hits a multiple of 10, the same script runs `sync.sh` (versioned tarball → `backup/versions/`) and mirrors the newest tarball + `P12.tar.gz` to `/mnt/oss/iya-platform-backups/` (idempotent via `.milestone-N` markers) | User directive 2026-10-17: "дублируй файлы туда, где они не пропадут" + "архив в backup каждые 10 языков"; the 2026-10-17 crash proved single-location state is fragile — translations are the most expensive artifact to re-create |
| ADR-015 | Versioned-tarball rotation (sandbox reset №2, 2026-10-18): `/mnt/oss/iya-platform-backups/` is the **complete** durable mirror of ALL `backup/versions/*.tar.gz` (20+ files, never deleted); `/home/user/backup/versions/` keeps only the **newest 4** tarballs. `sync.sh` output should be copied to /mnt/oss each checkpoint; when /home/user total size approaches ~110 MB, prune the oldest local tarball (it already lives in /mnt/oss) | Workspace snapshot cap ~128 MB: keeping all 20 tarballs (219 MB) + repo + P12.tar.gz in /home/user would silently truncate the snapshot and risk losing the project itself; /mnt/oss survives resets (it is where snapshots are stored) and proved writable |
| ADR-016 | Per-step quick archive (user directive 2026-10-18, "уезжаю на 8 часов"): after EVERY work step (i18n batch, code change) run `bash scripts/pack_p12_quick.sh` — it rotates `"$ARENA_WORKSPACE"/P12.tar.gz` → `P12_PRE.tar.gz` and rebuilds P12 WITHOUT the tests+lint gate (~2 s instead of ~30 s; the full `pack_p12.sh` gate still runs at every locale checkpoint). Manifest status block gets a `wip locales` line (exact per-locale key counts) so a successor sees the interruption point inside the archive itself. Two generations (P12 + P12_PRE) survive a mid-write crash | Context-overflow crashes are expected during long autonomous runs; a stale-by-one-step archive in two generations + git WIP commits + `backup/i18n/*.partial` make loss ≈ 0 |
| ADR-017 | **P14 ip_blocks edge enforcement** (2026-10-18): `src/Security/IpBlockGuard.php` runs in the Kernel pipeline right after the maintenance gate, BEFORE sessions/visit capture. Matches every resolved client address (ClientIp public/socket/internal + raw REMOTE_ADDR, v4+v6, bare IP = exact match) against non-expired `ip_blocks` rows; one indexed query per request, per-instance cache; fail-open on DB errors (pre-migration boot); kill switch `IP_BLOCKS_ENFORCE` (default true); response = bare 403 (JSON envelope `ip_blocked` for `wantsJson()`, minimal dark HTML otherwise — no rule details leaked); structured log `security.ip_block.enforced`. Guard registered **non-shared** in the container so its cache can never go stale across requests in one process. Enforcement is global (/health included) | The admin table existed since P11c but nothing read it — the platform's main known security gap (§9 P14). Gate-before-session means a blocked scanner costs one SELECT and zero side effects |
| ADR-018 | **P12 scope frozen at 42/66** (user directive 2026-10-18: «оставшиеся языки пропускаем и доделываем остальной план»): the remaining 24 locales (id, ms, az, af, bs, ca, hy, ka, kk, km, lb, lo, mk, mn, mr, my, ne, si, sq, sw, ta, te, ur, uz) are SKIPPED; en-fallback covers them at runtime. All i18n machinery (build, QA gates, backup ritual, milestone script) stays intact — if the user re-enables, resume exactly per the frozen §9 P12 instructions (next was `id`, formal Anda) | User decision; P13-P16 engineering work has higher value per hour than the remaining translations |
| ADR-019 | **P17 = Sandboxed Agent Contribution Pipeline** (user directive 2026-10-19): принят полный план `docs/AGENT-CONTRIB-PLAN.md` (фазы C0–C8): allowlist-челленджи на один sanitized файл → strict-JSON кандидаты в карантин → детерминированные гейты (L0 static / L1 hardened subprocess / L2 docker — промоция в prod только через L2) → ScoreEngine (машина) + pairwise reviewer-агенты (строгий CSV) → git-ветка → полный CI → beta-окно метрик → production с человеческим подтверждением (CONTRIB_AUTO_PROMOTE=false default); kill switches на каждом уровне; репутация/страйки; разделы обратной связи = reuse swarm sections (без новых таблиц). Ключевые принципы: код кандидата недоверенный и никогда не выполняется в web/worker-процессе; критерий «≥50% размера» отвергнут (размер — минорный вес); все CONTRIB_*-ключи в Config::defaults() (B-005). Комплект преемника: `AGENTS.md` (входная точка) + копии всех гайдов в корне workspace; сборка бандла теперь воспроизводима — `scripts/pack_bundle.sh` | Idea бриф пользователя 2026-10-19; существующий swarm/rate-limit/worker/CI-инфраструктурный слой покрывает ~70% нужды — новый код локализован в src/Domain/Contrib/ |
| ADR-020 | **P18 = восстановление раздела `/dashboard/settings`** (user directive 2026-10-19: «не пережили файлы какое-то из падений»): аудит `scripts/_inventory_audit.php` подтвердил — nav-пункт `/dashboard/settings` даёт 404 (маршрут не зарегистрирован ни в одном коммите после baseline `bb5a22a`), 20 хендлеров AccountController (profile/security/password/2FA×4/sessions×3/preferences×2/privacy×2/api-tokens×3/export/delete×2) и 3 subdomain-хендлера DashboardController (appearance/stats-policy/delete) отвечают 501; шаблоны настроек утрачены; ApiTokenService отсутствует; BETA.md §4 расходится с фактами. Домен и БД УЦЕЛЕЛИ (AuthService: 2FA/sessions/profile/changeEmail/changePassword/deleteAccount/exportUserData; таблицы api_tokens/totp_recovery_codes/sessions; Totp.php; Csrf в Kernel). План `docs/SETTINGS-PLAN.md` (S0–S4). Решения: D1 канонический URL `/dashboard/settings/*`, роуты `/account/*` остаются алиасами без редиректов; D2 SSR-hub без табов; D3 новый ApiTokenService (`iya_`+40hex, sha256-hash, prefix, ≤10 активных); D4 subdomain-трио — фаза S4; D5 i18n ключи в `_base.en`+ru, замороженные 24 локали не трогать; D6 тесты `tests/integration/AccountSettingsTest.php` на харнессе HttpSmokeTest + реальная сессия; D7 BETA.md исправить после S3. **P18 выполняется ПЕРЕД P17** | Инвентаризация 2026-10-19 (аудит 525 роутов: 23 стаба, 0 мёртвых render/ассетов); первоначальное ТЗ TASK-PROMPT-ORIGINAL.md строки 142–190 («Личный кабинет») |

---

## 2. Agent swarm (workstreams) — delegation map

Contracts: `docs/CONTRACTS.md`. Schema: `migrations/`. HTTP API: `app/api/v1/openapi.py`.

| Agent | Stream | Owns | Status |
|---|---|---|---|
| A1 `architect` | Contracts & state | REWORK-STATE.md, CONTRACTS.md, ADRs, sync/backup, git | 🟡 active |
| A2 `data` | DB & migrations | `migrations/{sqlite,mysql}/*.sql`, `src/Infrastructure/Db/Db.php` | ✅ core done (sqlite); mysql DDL pending |
| A3 `core-http` | HTTP core | `src/Http/*` (Request, Response, JsonResponse, Router, Route, Kernel, HostResolver, HostContext, SessionManager, Middleware/*) | ✅ done (111 tests) |
| A4 `security` | Security | `src/Security/*` — ClientIp, ClientIpResolver, IpUtil, UserAgentParser, GeoIp, SsrfGuard, RateLimiter, PasswordHasher, Totp, Csrf, **ThreatDetection**, **IpIntelService** (async AbuseIPDB + org/ASN display), **AbuseReportService** (outbound reports + spam intake) + `src/Web/Controllers/IntelController.php` | ✅ done |
| A5 `identity` | Auth & accounts | `src/Domain/Auth/*` (AuthService, AuthResult), TOTP, recovery codes, lockout, sessions | ✅ core done (P3); OAuth providers + account UI pending |
| A6 `links` | Links & redirect engine | `src/Domain/Link/{LinkService,RedirectEngine,CollectionService}.php`, `src/Domain/Subdomain/SubdomainService.php`, `TenantController::{resolve,qr}`, `src/Web/Controllers/Dashboard/{LinksTrait,CollectionsTrait}.php`, `src/Web/Templates/{links,dashboard/links,dashboard/collections}/*`, `src/Support/Seeder.php` | ✅ done — incl. all polish (CSV import, purge UI, collections UI, targeting/AB editor) |
| A7 `analytics` | Visit capture + rollups + charts | `src/Domain/Visit/*`, `src/Domain/Analytics/{PublicStatsService,OwnerStatsService,ReportsService,RollupBuilder,SavedViewService}.php`, `VisitQueue`, `MaintenanceWorker`, `tracker.js`, `realtime.js`, `TrackerController`, `TenantController` (public pages+stats+SSE), `Dashboard/{AnalyticsTrait,ReportsTrait}`, `src/Infrastructure/Export/{Csv,Xlsx,Pdf}Writer.php` | ✅ P5-P8 done + **P7 saved views done**; interactive JS charts optional later (server SVG ships) |
| A8 `frontend` | UI kit, layouts, dashboard | `public/web/css/*`, `public/web/js/{app,tracker}.js`, `src/Web/Templates/*` (layouts incl. **dashboard shell**, links pages, partials, pages, errors, docs), `src/Web/{View,Icon,I18n}.php` | 🟡 marketing+errors+docs+dashboard-links done; tenant public pages + analytics UI pending |
| A9 `swarm` | agent-swarm API | `app/api/v1/agent_swarm.py`, `app/domain/swarm.py` | ⬜ todo |
| A10 `i18n` | 30+ languages, RTL | `web/i18n/*` (66 catalogs), `src/Web/I18n.php`, `scripts/build_i18n.php`, `public/web/css/rtl.css` | 🟡 en/ru/uk/be/de/es 100 % (6/66); 60 locales pending translation |
| A11 `docs` | Docs for humans & LLMs | `docs/*` (15 files), `/llms.txt`, `/agents.json`, `/api/v1/openapi.json`, `docs/legal/*` | 🟡 AGENT_API/RULES/QUICKSTART/API/ERRORS/RATE_LIMITS/legal done; ARCHITECTURE/SECURITY/DEPLOYMENT/ANALYTICS/PRIVACY/I18N/CONTRACTS are placeholders |
| A12 `devops` | Docker, CI, scripts, backup | `scripts/{sync.sh,dev-router.php,vendor_fonts.py,build_i18n.php,smoke_http.py}`, `bin/iya`, `.env.example` | 🟡 scripts done; Dockerfile + CI pending |
| A13 `qa` | Tests + beta-tester loop | `tests/{run.php,TestCase.php,bootstrap.php}`, `tests/unit/**`, `tests/integration/HttpSmokeTest.php`, `scripts/smoke_http.py` | 🟡 223 tests / 1294 assertions + 149 HTTP checks green; beta journey pending |
| A14 `admin-billing` | Admin, plans, quotas, feature flags | `app/web/views/admin.py`, `app/domain/billing.py` | ⬜ todo |
| A15 `extras` | QR, XLSX/PDF export, PWA, webhooks, Telegram/email | `src/Infrastructure/Qr/{QrCode,QrMatrix,QrException}.php` (QR ✅ verified), link webhook action ✅; XLSX/PDF/PWA/notifications pending | 🟡 QR done |

---

## 3. Phase plan & progress

| Phase | Scope | Status |
|---|---|---|
| P0 | Env probe, workspace, backup scheme, git, state file | ✅ done |
| P1 | Config, DB, migrations, **client-IP fix (done, 32 tests green)**, UA parser, GeoIP, rate limit | 🟡 IP+DB done; UA/Geo/ratelimit pending |
| P2 | HTTP core: request/response, router, sessions, cookies, CSRF, security middleware, template engine, static files | ✅ done |
| P2b | Design system + marketing/error/docs pages + live smoke test | ✅ done |
| P3 | Identity: register/login, email verification, password reset, TOTP + recovery codes, sessions/devices, OAuth (config-gated) | ✅ core (OAuth providers + account UI remain) |
| P4 | Subdomains + links CRUD, action types, targeting, A/B, QR, import/export, bulk ops, trash, link audit log | ✅ **complete** — core+UI live-verified 13/13; polish: CSV import (auto-detect, export round-trips, skip reasons), purge UI (per-row/bulk/empty-trash), collections UI (CRUD + link-form assignment + `?collection=` filter), targeting/AB editor (rules CRUD + variant replace-set, engine-proven live 18/18) |
| P5 | Redirect engine + visit capture pipeline + tracker.js + consent + privacy modes | ✅ done (engine landed with P4; visits carry link_id/redirect_to/reason) |
| P6 | Public subdomain page + public/private stats policy engine | ✅ done (policy matrix, delay, password gates, hidden links, field masks, SVG chart, JSON API) |
| P7 | Dashboard: overview, visits table, realtime, links, filters, saved views | ✅ **complete** — overview/visits explorer/visit detail/SSE realtime/onboarding + saved views (chips bar, default auto-apply, manager /analytics/views; live 17/17) |
| P8 | Analytics: 14 report pages, rollups, heatmaps, geo, cohorts, funnels, retention, anomalies, exports (CSV/JSON/XLSX/PDF) | ✅ done (18 reports × HTML+JSON, heatmap, cohorts matrix, z-score anomalies, compare, all 4 export formats verified openable) |
| P9 | agent-swarm API (all 55 endpoints GET+POST), sections depth ≤ 6 + versioning, threads, messages FTS5 + idempotency, tasks claim/handoff/approve/deps, knowledge dedup+ratings, features voting, openapi.json, audit trail — live journey 53/53 | ✅ |
| P8.6 | Abuse reporting: outbound AbuseIPDB report queue (attacks from ThreatDetection + spam from mail-server intake), `POST /api/v1/intel/spam-report` endpoint (shared secret), optional MySQL drop-box poller (`SPAM_INBOX_DSN`, every worker pass ≈ 1 min), admin checkboxes + report budgets, `docs/ABUSE-REPORTING.md` | ✅ done (334 tests; smoke 164/164 incl. 15 intake checks; E2E 9/9 with real provider call; admin UI live-checked 24/24; commit `9e0360e`) |
| P10 | Teams, webhooks, notifications (email/Telegram), alerts, comments, notes, collections | ✅ **complete** — P10a notifications (`ce209f0`) · P10b webhooks (`f0174da`, live 25/25) · P10c alerts (`b40b331`, live 24/24) · P10d comments (`697c44b`, live 15/15) · P10e teams (`c851a7b`, live 17/17). Notes+collections shipped with P4 |
| P11 | Admin panel, plans/quotas, feature flags, maintenance mode, audit | ✅ done (admin UI live-checked 24/24, RBAC drill all passed) |
| P12 | i18n: runtime + RTL + localized formats — **FROZEN at 42/66 full coverage** (ADR-018, user directive 2026-10-18); все механизмы (build/QA/backup/milestones) целы; возобновление — §9 P12 (следующая `id`, formal Anda) | 🧊 frozen 42/66 |
| P13 | Design pass: dark/light tokens, RTL (logical properties), mobile, focus rings, unified empty states; invariants locked by testP13DesignInvariants | ✅ done (`25475a1`) |
| P14 | Load/security review: ip_blocks edge enforcement (ADR-017), RATE_LIMIT_* enforced, N+1 sweep, RollupBuilder implemented + EXPLAIN audit, edge drill 12/12 | ✅ done (`9534185`..`256baaf`) |
| P15 | QA beta loop: `scripts/_beta_journey.py` 13/13 golden path, onboarding-501 blocker fixed, `docs/BETA.md` + triaged fix list | ✅ done (`d93ccc2`) |
| P16 | Docs, Docker, CI/CD, CHANGELOG, release | ✅ |
| P17 | Sandboxed agent contribution pipeline (allowlist-челленджи → карантин → гейты L0/L1/L2 → ScoreEngine → pairwise CSV-review → beta-окно метрик → prod с human gate) — план `docs/AGENT-CONTRIB-PLAN.md`, фазы C0–C8 | 📋 planned (ADR-019, 2026-10-19) |
| P18 | Восстановление потерянного веб-слоя настроек: `/dashboard/settings` (hub + security/sessions/preferences/privacy/api-tokens/export/delete) + 20 стабов AccountController + 3 subdomain-стаба + ApiTokenService — план `docs/SETTINGS-PLAN.md`, фазы S0–S4. **Приоритет ВЫШЕ P17** (чинит мёртвый nav-пункт и 23 роута-501) | 📋 planned (ADR-020, 2026-10-19) |

---

## 4. Database schema

Authoritative source: `migrations/001_init.sql` (+ later migrations).
Machine-readable dump: `docs/SCHEMA.md` (regenerated by `python3 scripts/dump_state.py`).

Domains: identity (users, sessions, 2fa, oauth, api_tokens), tenancy (subdomains, teams,
memberships, invites), links (links, variants, targeting, collections, comments, audit, trash),
analytics (visits, visit_events, consent, rollups, realtime), swarm (agents, sections, threads,
messages, tasks, knowledge, features, ratings, complaints), platform (settings, flags, jobs,
audit_log, webhook deliveries, notifications, plans/quotas).

---

## 5. API contracts

* Public/user API + admin API: `app/api/v1/openapi.py` → `/api/v1/openapi.json`
* Agent-swarm API (GET-first): `docs/AGENT_API.md`, `/llms.txt`, `/agents.json`
* Cross-stream internal contracts: `docs/CONTRACTS.md`

### 5.1 Visit capture contract (P5, frozen — code against it)

* Hot path: `Kernel::finalize() → VisitService::capture(Request, target, options)`
  * `target`: `subdomain_id, link_id, user_id, redirect_to, redirect_reason, response_status, response_time_ms, landing_page, legacy_visit_id, privacy_mode`
  * `options`: `privacy_mode` (owner override), `synchronous` (bool)
  * Builds the full payload → `PrivacyPolicy::apply()` **before** the payload rests
    → `VisitQueue::push()` (durable, at-least-once). Anonymous mode: no row, only
    `rollups` day-counter increments. Returns queue id (0 = not recorded).
* Worker: `MaintenanceWorker::drainQueue() → VisitService::persist(payload)`
  * Idempotent by `request_id`; geo enrichment; visitor state
    (`is_new_visitor/is_returning/is_unique/first_seen_at`); final `status`
    (`blocked > suspicious > bot/filtered > returning > new`); column-safe insert.
* Tracker beacon (`public/web/js/tracker.js`, consent-gated, extended mode only):
  * `POST /tracker/collect` — JSON beacon, short keys (`sw,sh,vw,vh,dpr,cd,or,ts,mtp,cc,dm,ct,ce,dl,rtt,js,ck,ls,ss,gl,wr,wa,ab,bl,bc,loc,tz,tzo,tl,top,sd,ic,dv,dmo,vid,sid,rid`)
    validated against `TrackerController::FIELD_MAP` → `VisitService::annotateOrDefer()`.
  * `GET /tracker/collect` → 1×1 GIF. `X-Iya-Tracked: 0|1`.
  * Race handling: beacon/consent arriving before the page-view row is drained are
    re-queued as `_annotate_rid`/`_consent_rid` tasks; the worker retries
    ("row not there yet") or acks ("policy refused — permanent").
  * `POST /tracker/consent` — `consent=full|minimal|none&rid=…` → cookie
    `iya_consent` + `consent_records` upsert + (deferred) visit annotation.
    `GET /tracker/consent` → JSON state.
* Cookies (first-party): `iya_consent` (180 d), `iya_vid` (1 y), `iya_sid` (30 min),
  `Domain=.iya.ru` on platform hosts, host-only on custom domains, HttpOnly, SameSite=Lax.
* Threat scanner (`src/Security/ThreatDetection.php`): 13 categories, severity-weighted
  (low 2 / medium 3 / high 5 / critical 7), `THREAT_SUSPICIOUS_SCORE=5`,
  `THREAT_BLOCK_SCORE=12`; verdicts land in `is_hack/hack_type/hack_details/status`;
  **flags never drop** — attacks are recorded as analytics data.

### 5.2 Redirect engine contract (P4, frozen)

`organization.iya.ru/<path>` → `Kernel::fallback` → `TenantController::resolve` →
`RedirectEngine::handle(Request, Context, subdomainRow)`:

1. exact lookup `links(subdomain_id, path)` (UNIQUE index; path normalised:
   lower-case, slashes trimmed, segments `[a-z0-9._-]{1,64}`, depth ≤ 6,
   first segment not in `LinkService::TENANT_RESERVED` — note: `team` IS allowed,
   the spec's flagship path is `/team/vasiliy_ivanov/`);
2. `$context->link` is set on EVERY outcome (even refusals) → the Kernel's
   `captureVisit()` forwards `link_id/subdomain_id/user_id/redirect_to/redirect_reason`
   into the visit payload; reasons: `via:targeting:<kind>`, `via:ab:<variant>`,
   `expired`, `not_started`, `disabled`, `click_limit`, `one_time_used`,
   `deleted`, `private`, `password`, `broken_target`, `file_missing`, else `link:<action>`;
3. availability gate → refusal pages: 410 (`links/gone`), 403 private, password
   form (200, CSRF-gated POST unlock, session-scoped);
4. targeting rules (`link_targeting_rules`, priority DESC, first match wins;
   kinds: country/region/language/device/os/browser/platform/referrer/ip_cidr/
   time_of_day/weekday/cookie; `asn` never matches offline) may override
   target/action (targets re-validated via `isSafeTarget`, webhook → SSRF);
5. A/B (`ab_test.enabled` + `link_variants` weights): stable bucket
   `crc32(ip|ua) % total_weight`; impressions/clicks counters;
6. actions: redirect 302/301(`meta.permanent`) · page/card/message (markdown is
   escaped by `Markdown::toHtml`; `content_format=html` passes
   `Markdown::sanitizeHtml` — DOM whitelist: drops script/iframe/handlers/
   javascript:) · file (realpath-confined to `var/data/uploads`) · webhook
   (SSRF-guarded, `X-Iya-Signature` HMAC when `webhook_secret`, then redirect
   to `target_url` or a 200/502 result page) · stats → 302 `/stats` ·
   qr → inline SVG page (`links/qr`; encodes the target URL, else the link's
   canonical URL so scans always land on the platform) · form/iframe → honest
   pending page (CSP `frame-src 'none'` blocks iframe embeds until the CSP
   policy phase);
7. `click_count`++ atomically per served click; `unique_click_count` is the
   rollup worker's job (derived from `visits.is_unique`).

Demo data: `php bin/iya seed [--fresh]` → `organization.iya.ru` with 12 links
covering every action and gate (link password `demo123`; owner demo@iya.ru /
Demo-Password-2026!).

### 5.3 IP intelligence contract (org/ASN + AbuseIPDB, frozen)

Requirement (user, 2026-10-17): show the organisation responsible for the
visitor IP ("AS42610 OJSC National Cable Networks") and enrich IPs from
AbuseIPDB **asynchronously**, with admin-configurable key/collection/display/
rates.

* **Never on the hot path**: visit persisted by the worker →
  `IpIntelService::consider(ip_public)` → `ip_intel_queue` (UNIQUE(ip)
  dedupes concurrent visitors). Gates: enabled + key set, IP valid/public/
  non-documentation (LAN/reserved/TEST-NET are **never** sent anywhere),
  re-check window (`ipintel.recheck_days`), daily cap.
* `MaintenanceWorker` task `ip_intel` → `drainQueue()` with budget
  `min(rate_per_min − calls last minute, daily_cap − calls today, due rows)`;
  success → upsert `ip_intel` (score/isp/domain/hostnames/reports/country —
  each field gated by the `ipintel.collect` checkboxes: the operator decides
  retention, not the provider); failure → linear backoff, drop after 5
  attempts, **401 drops immediately** (a bad key must not spin); logs carry
  `ip_hash`, never the raw IP.
* Settings live in the `settings` table (`ipintel.*`, admin UI
  `/admin/settings`; `ABUSEIPDB_KEY` env is a headless fallback). Switches:
  enabled, key (write-only field), collect[6], show_dashboard,
  show_public_org, rate_per_min, daily_cap, recheck_days, min_score_flag.
* Display: `IpIntelService::orgLine(visit, intel)` → "AS<n> <org>" falling
  back visit geo → intel isp → intel domain. Dashboard visits list/detail:
  org line + `abuse <score>` badge at ≥ min_score_flag (LEFT JOIN ip_intel).
  Public stats: org line only when `show_public_org` AND the owner's IP/geo
  switches allow; **abuse scores never appear publicly**.
* Migration `005_ip_intel.sql`: `ip_intel` (PK ip, +ip_hash for post-
  anonymisation joins) and `ip_intel_queue`; settings rows seeded via
  INSERT OR IGNORE.

### 5.4 i18n contract (P12, frozen)

`web/i18n/<locale>.json` files are **GENERATED** — never edit them by hand.
Sources, merged in this order (later wins) by `php scripts/build_i18n.php`:

1. `legacy/iya-ru-source/lang/<code>.php` — the original 43 translations of
   the iya.ru IP-information service (53 flattened keys);
2. `web/i18n/_base.en.json` — the **platform key set** (source of truth,
   1409 keys as of P12.0; flat dotted keys, sorted);
3. `web/i18n/_platform.<code>.json` — platform translations for that locale.

Rules:
* New UI string → add the key to **all** templates via `en.json`-first
  workflow: put it in `_base.en.json` (+ `_platform.ru.json` for Russian,
  ru is kept 100 %), then `php scripts/build_i18n.php` regenerates every
  catalog with `en` fallback and rewrites `docs/I18N-STATUS.md`.
* Output catalogs are ksorted with a `_meta` header; coverage is measured by
  **presence in the `_platform.<code>.json`** file, not by difference from
  English (a translation may legitimately equal the source).
* `tests/unit/Web/I18nSyncTest.php` is the drift guard: it fails when
  `_base.en.json` ≠ `en.json`−legacy, when `_platform.ru.json` misses keys,
  or when any catalog lacks platform keys. **Red guard = someone edited a
  generated file** → move the change into the sources and rebuild.
* Placeholders `{name}` must survive translation — verify with the regex QA
  one-liner in WORKLOG (P12.1) before committing a locale.
* Translating a locale = write `_platform.<code>.json` in batches (~400
  keys), `php scripts/build_i18n.php --locale=<code>`, placeholder QA,
  live `?lang=<code>` spot-check. Partial files are fine (fallback covers
  the rest); coverage % in I18N-STATUS tracks progress.
* **Activation gate**: a locale only renders when it is listed in
  `SUPPORTED_LOCALES` (`.env`, mirrored in `.env.example`; runtime
  whitelist in `LocaleResolver`). The build knows 66 locales but the env
  list enables fewer — when a locale reaches 100 % (or a usable level),
  add its code to BOTH files, else `?lang=<code>` silently falls back to
  en (P12.2 discovery: be was translated but not whitelisted).

---

## 6. File map

See `docs/FILE_MAP.md` (regenerated by `python3 scripts/dump_state.py`).

---

## 7. Commands

```bash
cd /home/user/iya-platform

python3 scripts/migrate.py            # apply migrations (add --reset to rebuild)
python3 scripts/seed.py               # demo data: org subdomain, links, visits, agents
python3 scripts/serve.py --port 8080  # dev server (apex + wildcard subdomains + agent-swarm API)

python3 -m unittest discover -s tests -t . -v          # all tests
python3 tests/qa/run_beta_journey.py                    # full beta-tester journey + report
python3 scripts/dump_state.py                           # regenerate SCHEMA.md / FILE_MAP.md
bash scripts/sync.sh                                    # mirror to /workspace/{work,backup}
```

Local hosts trick for subdomain testing (or use `?_host=` debug override in dev):
```
127.0.0.1 iya.ru organization.iya.ru agent-swarm.iya.ru www.iya.ru
```

---

## 8. Known bugs / open questions

| ID | Sev | Description | Status |
|---|---|---|---|
| B-001 | info | Production needs a reverse proxy setting `X-Forwarded-For`; `TRUSTED_PROXIES` must list it, otherwise the app falls back to the socket peer (`ip_method=direct`) | documented |
| B-002 | info | GeoIP is offline-first: without a bundled/remote DB, geo resolves only for reserved/private ranges (`geo_source=offline-none`); provider plug-in points exist in `geoip.py` | by design |
| B-003 | info | A browser cannot reveal the client LAN IP without WebRTC; `ip_internal` is filled by the optional consent-gated tracker or from proxy headers when the client is inside the trusted network | by design |
| B-004 | low | `admin.ip_blocks_hint` i18n text still says edge enforcement "ships with the P14 security pass" — it HAS shipped (ADR-017). Updating the key means re-touching 42 `_platform.*` files; defer to the next i18n sweep (or accept stale wording) | open |
| B-005 | low | `Config::env()` gives **.env precedence over real environment variables** (envCache checked first) while the class docblock documents the opposite order (env first, .env fallback). Deployments overriding via env vars while a .env exists are silently ignored. Discovered in P14(d) — drills now rewrite .env temporarily. Decide: fix precedence (behaviour change) or fix the docblock (accept .env-first) | open |
| B-006 | **high** | Потеря веб-слоя настроек (раннее падение, подтверждено аудитом 2026-10-19): nav-пункт `/dashboard/settings` → 404 (маршрута нет); 23 зарегистрированных роута отвечают 501 (20× AccountController: profile/security/password/2FA/sessions/preferences/privacy/api-tokens/export/delete; 3× DashboardController: subdomain appearance/stats-policy/delete); шаблоны account-страниц (кроме 4 рабочих) и ApiTokenService утрачены; BETA.md §4 расходился с фактами (исправлен). Домен+БД уцелели. Лечение = фаза P18 (`docs/SETTINGS-PLAN.md`, ADR-020); аудит-инструмент `scripts/_inventory_audit.php` | planned (P18) |
| B-007 | low | `php bin/iya migrate --reset` дропал таблицы в порядке sqlite_master при включённых FK: дроп родителя (`users`) раньше детей ронял reset («no such table: main.users») и оставлял dev-БД полусломанной (дети без FK-целей). Обнаружено при инвентаризации P18. **ИСПРАВЛЕНО 2026-10-19**: сначала дропаются triggers/views, затем `PRAGMA foreign_keys=OFF` → дроп таблиц → `ON`; проверено на симуляции рваной БД (94 объекта, полный re-migrate + seed). Тестовые БД не затрагивались (тесты создают свои) | ✅ fixed |

---

## 8.1 Completed so far (details)

* **Legacy sources archived**: `legacy/iya-ru-source/` — 172 files, sha256-verified
  against `https://iya.ru/qwen_access/_FILES.txt`. Root causes found in legacy code:
  * `IPInfoApplication::getClientIP()` trusted raw `HTTP_X_FORWARDED_FOR` (spoofable,
    and it kept the whole comma-separated chain as one string).
  * nginx `listen 192.168.31.31:443` ⇒ `REMOTE_ADDR` was the LAN address of the proxy,
    which is exactly the "internal IP shown as visitor IP" bug.
  * `ip_info.ip` was **UNIQUE** ⇒ one row per IP, visit history overwritten
    (`ON DUPLICATE KEY UPDATE`), no real analytics possible.
  * `SecurityConfig::VPN_RANGES` treated RFC1918 prefixes as "VPN likely".
  * CDN dependencies (cdnjs Font Awesome, Yandex.Metrika) violate the no-CDN rule.
  * `info.php` (phpinfo) publicly reachable; `display_errors=1` in production.
* **New platform (PHP 8.5 target, 8.2-compatible syntax, zero composer deps)**:
  * `src/Core/`: Config (.env + env), Container, Bootstrap (composition root), Version.
  * `src/Infrastructure/Db/Db.php`: PDO wrapper (sqlite/mysql), portable upsert,
    trigger-aware migration splitter, checksummed migrations.
  * `src/Infrastructure/Log/Logger.php`: JSON structured logs + secret redaction.
  * `src/Security/`: IpUtil (CIDR tables incl. CGNAT/TEST-NET/multicast, HMAC hashing,
    IPv6 /64 aggregation, anonymisation), ClientIp (value object + UI projection),
    ClientIpResolver (trusted-proxy chain walking, CDN headers, RFC 7239, spoof logging).
  * `migrations/sqlite/001_core.sql` + `002_swarm.sql`: **77 tables, 141 indexes,
    6 triggers, 2 FTS5 indexes**; `visits` has 127 columns incl. 15 IP fields.
  * `bin/iya` console: doctor / migrate / seed / serve / worker / lint / schema:dump.
  * `tests/`: dependency-free xUnit runner (`php tests/run.php`) + 32 green tests.
  * `web/fonts`: self-hosted Inter + JetBrains Mono subsets (latin/latin-ext/cyrillic/
    cyrillic-ext/greek) — no CDN.
  * **P3 identity (done)**: `AuthService` (register/login/lockout/remember-me with
    rotation/2FA challenge/replay-safe TOTP + recovery codes), `AuthResult`,
    rate-limit schema fix (`rate_limits.created_at`, key consistency between
    `hit/blockedFor/reset`), auth templates wired.
  * **P5 visit capture core (done)**: `PrivacyPolicy` (4 modes as server-side
    allow-lists; consent only ever reduces; DNT/GPC downgrade to minimal),
    `VisitService` (capture → queue → persist, 127-column mapping, visitor
    state, deferred annotations), `ThreatDetection` (legacy HackDetectionService
    reorganised: 13 categories, scored, ~0.04 ms/clean request),
    `TrackerController` (collect pixel+beacon, consent), `tracker.js`
    (capability flags + engagement only — no fingerprinting), Kernel capture
    integration, tenant-zone tracker routes, worker geo enrichment.
  * **Db::upsert fix**: conflict-path `lastInsertId()` staleness + tables without
    an `id` column (regression: 500 on repeated `/tracker/consent`).
  * **P8.6 abuse reporting (done, ADR-013)**: migration `006_abuse_reports.sql`
    (`spam_reports` UNIQUE(ip,evidence_hash), `abuse_report_queue`
    UNIQUE(dedupe_key), `abuse_report_log` ip_hash-only audit + budget counters;
    8 new `ipintel.report_*/inbox_*` settings). `AbuseReportService`: threat
    verdicts (severity ≥ `report_min_severity`, default 5) and ingested spam →
    one durable queue → worker drain with per-minute/daily budgets, 72 h dedupe
    window, 15-min backoff on provider 429, drop at 5 attempts / bad key,
    category mapping (sql_injection/xss/…→18, rce/deserialization/ssrf→15+18,
    webshell→15, probe→16, email_spam→11, phishing→7, …). Intake:
    `POST /api/v1/intel/spam-report` (`IntelController`, X-Intel-Secret/Bearer,
    JSON+form, idempotent, 60/min + 10-auth-fail/5min throttles, self-describing
    GET) + optional MySQL drop-box poller (`SPAM_INBOX_DSN`, worker task
    `spam_inbox`, claims status='new', marks done/error; skips cleanly without
    pdo_mysql). Admin card: checkboxes, budgets, severity select, secret with
    one-time reveal (?secret=1) + REGENERATE/CLEAR. Docs `ABUSE-REPORTING.md`,
    openapi path + `inbox_disabled`/`invalid_report` error codes. Worker visit
    drain now captures `persist()` id → `queueFromVisitPayload($payload, $id)`.

  * **P4 polish complete (2026-10-17)**: CSV import (`parseCsvLinks` RFC4180,
    auto-detect, IMPORT_COLUMNS whitelist, exports round-trip); trash purge
    (per-row/bulk/empty-trash, `purgeTrashed`, platform `audit_log` trace —
    `link_audit` cascades with the link by design); collections
    (`CollectionService`, transliterated slugs, delete detaches links);
    targeting/AB editor (rules CRUD + variant replace-set keeping stats,
    `links.ab_test` switch). **Kernel flash-PRG bug fixed** (redirects no longer
    drain flashes); app.js submitter-level `data-confirm`. Live proofs:
    `_import_check` 10/10, `_purge_check` 14/14, `_collections_check` 19/19,
    `_targeting_check` 18/18 (engine-level: cookie-rule override, toggle-off,
    100% variant capture, impressions).

## 8.2 Verified working right now (do not regress)

```bash
php bin/iya doctor                              # environment self-check
php bin/iya migrate                             # 8 migrations (001..008)
php tests/run.php                               # 366 tests / 2508 assertions
php bin/iya lint                                # 176 files, 0 failures
php bin/iya serve --host=127.0.0.1 --port=8080 &  # dev server (NOTE: --host=/--port= form!)
python3 scripts/smoke_http.py --port 8080       # 164 live HTTP assertions (incl. 15 spam-intake)
python3 scripts/smoke_swarm.py 8080             # 53 live agent-swarm assertions (positional port!)
# one-off live journeys (each flips dev-DB rows and restores them; need a running server
# and the seeded demo admin demo@iya.ru / Demo-Password-2026!):
#   scripts/_admin_settings_check.py 24/24 · _import_check.py 10/10 · _purge_check.py 14/14
#   scripts/_collections_check.py 19/19 · _targeting_check.py 18/18 · _e2e_abuse_reporting.py 9/9
#   scripts/_views_check.py 17/17 · _notifications_check.py 19/19 · _webhooks_check.py 25/25
#   scripts/_alerts_check.py 24/24 · _comments_check.py 15/15 · _team_check.py 17/17
#   scripts/_admin_pages_check.py 51 · _swarm_admin_check.py 16/16 · _sec_admin_check.py 18/18
#   scripts/_rbac_check.py 19/19 (two real invited users: editor writes, viewer read-only)
php bin/iya worker --once                       # queues, rollups, retention, ip_intel, abuse_reports, spam_inbox, alerts, outbox
```

P8.6 verified live (2026-10-17): `scripts/_admin_settings_check.py` — full admin
journey 24/24 (login as seeded `demo@iya.ru`, settings card renders, save/
persist/unchecked-false, secret regenerate → 48-hex → one-time reveal).
`scripts/_e2e_abuse_reporting.py` — outbound E2E 9/9: real SQLi-looking request
(XFF 8.8.8.8) → flagged visit → worker → attack report queued; spam intake 201
+ queued; worker drain made **real AbuseIPDB HTTPS calls → 401 (dummy key) →
poison rows dropped + logged** (`unauthorized_bad_key`), spam_report closed
'rejected', queue empty. Both scripts flip settings rows in the dev DB and
restore them; keep them out of the phpunit suite (they need a live server).

P5 pipeline verified live over HTTP (2026-10-16): page view → queue → worker →
`visits` row (external IP primary, `ip_method`, UA parsed, month bucket);
`DNT:1` → `privacy_mode=minimal` with no IP stored; XSS query → page still 200,
row `status=suspicious, is_hack=1, hack_type=xss`; `/wp-admin/` → 404 served,
row `hack_type=probe`; consent POST → `iya_consent=full` cookie (Domain=.iya.ru)
+ `consent_records` + deferred `consent_given=1` on the queued row; beacon in
standard mode → refused by policy, deferred task acked (not retried); queue
depth 0 after drain.

P9 swarm API verified live over HTTP (2026-10-17, scripts/smoke_swarm.py 53/53):
register (idempotent replay, secrets shown once, SHA-256-at-rest proven),
Bearer + ?token auth, 401 unauthenticated/invalid_token envelopes, sections
depth 2..6 OK / depth 7 → 422 depth_exceeded, versioned updates, ownership 403,
anonymous sees only public; messages: checksum, client_message_id replay →
meta.replayed, FTS5 search, mentions → inbox, votes (±1, self-vote 422), edits
→ version+history, foreign edit 403; tasks: claim lease + 409 double-claim,
claimant-only transitions, review → approve (self-approval 422) → done,
handoff, one-call context bundle, deps blocked_by, max_attempts; knowledge:
content-hash dedup → meta.duplicate, ratings → usefulness, markdown export
(text/markdown + Content-Disposition); features: public roadmap, vote score
recalc; format=html rendering; POST form writes; rotate kills the old token;
recover with recovery_code (wrong code → 403); openapi.json 3.0.3 with 40+
paths; rate limiting live-proven (register bucket 429 + retry_after).

P4 engine verified live over HTTP (2026-10-17, seeded `organization.iya.ru`):
`/team/vasiliy_ivanov/` → 302 deynekin.com (+`X-Iya-Link`), message/card pages
200 with rendered markdown, `/secret` → CSRF password form (wrong → 403,
correct → content, session unlock persists), `/expired` → 410, one-time &
max-clicks gates → 302,302 → 410, A/B stable bucket → example.com/a, device
targeting → /app (mobile UA) vs /web (desktop), `/file` → attachment 200,
webhook → 302 /thanks after firing, `/qr` → pending page, unknown → 404;
every visit row carries link_id + redirect_reason (incl. refusals); click
counters exact (once=1, limited=2).

P4 dashboard UI verified live over HTTP (2026-10-17, demo@iya.ru): login →
`/dashboard/links` (shell + seeded rows), create → 302 show (QR SVG + canonical),
edit/update, audit trail (created+updated with redacted secrets), delete →
trash → restore, JSON+CSV export attachments, bulk disable, QR PNG endpoint,
import JSON (message action ✓, bad slug rejected, javascript: URL rejected,
duplicate path skipped, imported links start disabled), CSRF-less POST → 403,
anonymous → 302 /auth/login. Smoke still 149/149.

P6 public stats verified live over HTTP (organization.iya.ru, real visit data):
index (brand + links + counters + QR + visits teaser), /stats (SVG bar chart,
totals 40/9/6 bots/1 llm/1 suspicious, tops device/link/country, masked visits
table with IP + bot badges, pagination), /stats.json (agent envelope: totals,
by_day×30, top{}, visits with public columns only — no hashes/consent/threat
detail), /links catalogue, /about. Policy matrix live: stats_public=0 → 403
hidden page; stats_public_until past → 410; stats password → form (wrong → 403,
correct → 302 → session unlock → 200); publish_delay 24h → fresh visits absent;
hidden link → excluded from lists AND aggregates AND tops; stats_show_ip=0 →
IP column gone. 247 tests / 1637 assertions green.

P7 dashboard verified live over HTTP (2026-10-17): full onboarding cycle for a
fresh user (register → email verify token → login → /dashboard shows the
onboarding form → availability probe JSON → POST /dashboard/subdomain creates
`testcorp.iya.ru` → 302 /dashboard/links/new → tenant page live with brand);
demo owner: overview (counters, SVG chart, 6 top cards), visits explorer
(25 rows/page, q/status/bot/hack filters, range presets), visit detail (8
groups, all 127 columns incl. hashes/threat verdict — owner scope), visit
JSON, realtime page + **SSE stream live**: `hello` cursor frame, 4 `visit`
events pushed while traffic was generated in parallel, `stats` frames with
online_now, auto-reconnect contract (30 s window, Last-Event-ID resume);
tenant `/stats/stream` → 200 text/event-stream (masked). 257 tests /
1671 assertions green.

P8 verified live over HTTP (2026-10-17): **18/18 report pages** render
(overview/visits/uniques/links/subdomains/geography/devices/browsers/sources/
utm/bots/suspicious/compare/cohorts/funnels/retention/realtime/anomalies) and
**18/18 .json twins** return valid envelopes; retention cohort matrix,
anomaly KPIs (mean 1.5, σ 5.7, 2 flagged), compare table (45 vs 0, 12 rows);
exports: CSV (UTF-8 BOM), XLSX (**validated as a real OOXML package**: 6 zip
parts, 46 rows, sheet XML parses), PDF (**opens in pypdf**, text extracts),
JSON envelope; anon → 302. 275 tests / 1762 assertions green, lint 160/0,
smoke 149/149.

IP intelligence verified live over HTTP + worker (2026-10-17): admin settings
page (key field write-only, 6 collect checkboxes, rate inputs) saves to
`settings`; anonymous tenant visit with `X-Forwarded-For: 8.8.8.8` (trusted
proxy) → row `ip_public=8.8.8.8, ip_method=proxy, link_id` set → worker
persist **auto-queued the IP** → same pass called the real AbuseIPDB API
with a dummy key → `401 → dropped` immediately, logged with ip_hash only;
display: dashboard visits list shows "AS42610 OJSC National Cable Networks"
org line + `abuse 91` badge, visit detail renders the intel card (ISP,
domain, 777 reports, hostnames, fetched_at), public stats show the org line
only while `show_public_org=1` (toggled off → line gone; dashboard keeps it),
abuse score never public. 291 tests / 1834 assertions green, lint 163/0,
smoke 149/149. Note: `.env` sets `ADMIN_REQUIRE_2FA=false` for dev;
production keeps true.

Confirmed live over real HTTP (`scripts/smoke_http.py`, 149/149):
home/about/pricing/terms/privacy/agent-rules/changelog/status/docs/*/404/405/HEAD,
robots.txt, sitemap.xml, manifest.webmanifest, security.txt, /metrics,
/api/v1/{health,status,openapi.json,errors,languages}, /llms.txt, /agents.json,
all CSS/JS/font/image assets with correct content types, `?lang=` + language
subdomains + hreflang + RTL, www→apex 301, tenant and swarm host zones, CSP
nonce matching the inline scripts, and the IP panel showing
**`IP: 203.0.113.45`** as the primary value with **`Internal IP: 127.0.0.1`**
as the secondary line, plus a visible "forged IP headers ignored" badge when an
untrusted peer sends forwarding headers.

Controllers are wired but most are still generated stubs that answer
`501 not_implemented` (HTML page or JSON envelope) — the URL surface is stable,
the handlers land phase by phase.

## 9. NEXT STEP FOR A NEW AGENT

**Next: P18 — раздел `/dashboard/settings` + восстановление аккаунт-слоя (plan only, code not started).**
Read `AGENTS.md` → `docs/SETTINGS-PLAN.md` (инвентаризация потерь §1, фазы S0–S4)
→ start with **S0 (роуты §4 + hub-страница: nav перестаёт 404-ить)**. P18 идёт
ПЕРЕД P17 (ADR-020): он меньше, восстанавливает утраченное при падениях и чинит
мёртвый пункт навигации. После P18 — **P17** по `docs/AGENT-CONTRIB-PLAN.md`
(C0→C8). Rules 1–11 below still apply (checkpoint ritual, pack_p12_quick after
every step, rule-11 report each round).
Progress formula for rule 11: done = (11 фаз + P12 + P13..P16 + S/5 + C/9) / 18,
где S = завершённые S-фазы P18 (S0–S4), C = завершённые C-фазы P17 (C0–C8).
Инструмент инвентаризации: `php scripts/_inventory_audit.php` (read-only).

Исторический порядок работ (P12–P16, всё ЗАВЕРШЕНО или заморожено):

1. **P12 (in progress)** — read §5.4 first (generated catalogs, `_base` /
   `_platform` sources, I18nSyncTest guard). Coverage **42/66**:
   en, ru, uk, be, de, es, fr, it, pt, pl, tr, zh, ja, ko, nl, sv, no,
   da, fi, cs, sk, sl, ro, hu, el, bg, hr, sr, lt, lv, et, is, ga, mt, fil, ar, he, fa, hi, bn, vi, **th** all at 100 %
   (1409/1409, placeholder QA clean, live-verified). Voice conventions locked in:
   **de = informal du-form** (home.hero_title "Deine Links, deine
   Statistiken"; encoded by `HttpSmokeTest::testLangParameterOverridesAcceptLanguage`);
   **es = informal tú** ("Tus enlaces, tus estadísticas"); **fr = formal
   vous** ("Vos liens, vos statistiques"); **it = informal tu** ("I tuoi
   link, le tue statistiche"); **pt = você/seu** ("Seus links, suas
   estatísticas"); **pl = informal ty-form** ("Twoje linki, Twoje
   statystyki"); **tr = formal siz** ("Linkleriniz, istatistikleriniz", per
   legacy `tr.php` "IP Adresiniz"; buttons stay infinitive, sentences use
   -siniz); **zh = polite 您** ("您的链接，您的统计", per legacy `zh.php`;
   no plural forms, {count} phrases simplified); **ja = polite です/ます**
   ("あなたのリンク、あなたの統計", per legacy `ja.php` 「あなたのIPアドレス」;
   buttons are noun/suru-stem, no plural forms). Keep those registers when
   re-touching.
   * **FROZEN (ADR-018, user directive 2026-10-18): the remaining 24 locales
     are SKIPPED** — 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. Runtime covers them with
     en-fallback; all machinery (build/QA/backup/milestones) stays intact.
     If re-enabled by the user: resume with `id` (formal Anda per legacy
     id.php «Alamat IP Anda»), then ms (same Anda), then per modern-UI
     convention with the register recorded in `_meta.note`; same 7-batch
     pattern + script-guard + pack_p12_quick per batch.
2. **P13 design pass — ✅ COMPLETE 2026-10-18** (`25475a1`): audited dark
   theme (token-driven, no-flash script), RTL (logical properties everywhere,
   rtl.css conditional — verified live on he), mobile (viewport, breakpoints,
   .table-wrap), focus rings (global :focus-visible). Empty states unified:
   links/index, views, collections now use the .empty component; table
   empties already systematised (.table__empty ×29). Invariants locked by
   HttpSmokeTest::testP13DesignInvariants. ur catalog remains the only RTL
   locale not translated (frozen with P12).
3. **P14 load/security review — ✅ COMPLETE 2026-10-18** (all five items;
   commits `9534185`, `92f8ccd`, `e36873a`, `abd9b37`, `256baaf`):
   ip_blocks edge enforcement (ADR-017); RATE_LIMIT_DEFAULT/API/REDIRECT
   enforced with kill switches (tenant zone exempt from global — own tighter
   buckets; /health exempt); N+1 swept (agentName memo + primeAgentNames in
   7 list paths, VisitService dedupe hoist); RollupBuilder implemented
   (day+hour, subdomain+global, idempotent) + EXPLAIN audit (all SEARCH via
   ix_visits_sub_time; recent lists order by visited_at — covered; no new
   indexes); drills: `python3 scripts/_p14_edge_check.py` (12/12, repeatable,
   restores .env) + `_sec_admin_check.py`/`_rbac_check.py` all passed.
   Follow-ups parked: rewire OwnerStats series/heatmap reads onto `rollups`
   when volumes demand; B-005 Config env precedence; B-004 stale i18n hint.
   **NEXT: P13 design pass (item 2 above).**
4. **P15 beta loop — ✅ COMPLETE 2026-10-18** (`d93ccc2`):
   `scripts/_beta_journey.py` (13 checks, deterministic, self-cleaning;
   clears rate_limits + failed_login_count/locked_until first so reruns
   never trip brute-force guards); `docs/BETA.md` (environment, golden
   path, feedback channels, triaged fix list, full drill suite). Fixed the
   onboarding 501 stub the journey exposed. Findings documented: HTML
   checkbox semantics (API-style POSTs must send `is_enabled=1`), account
   lockout bucket key `rl:<acct>:account` (not matched by '%auth%').
5. **P16 Docker/CI/release — ✅ COMPLETE 2026-10-18** (`a6346e5`):
   Dockerfile (php8.2-cli-alpine + pdo_sqlite/sqlite3/mbstring/intl/zip/curl/
   opcache, migrate-on-boot CMD, healthcheck on /api/v1/health, VOLUME
   /app/var) + .dockerignore; `.github/workflows/ci.yml` (doctor → migrate →
   lint → tests → build_i18n --check; separate docker build + in-image smoke
   job); `php bin/iya release:notes [version]` (CHANGELOG section extractor);
   `docs/RELEASE.md` (semver policy table, release ritual, changelog
   discipline); deferred P10 emitters shipped: **link.target_down**
   (TargetChecker, worker task `target_checks`, SSRF-guarded, emits once per
   ok→down transition, TARGET_CHECK_BUDGET gate — dev .env = 0) and
   **agent.task** (SwarmService optional WebhookService dep; created/claimed/
   status_changed/handoff fan-out to subscribed users). +10 tests
   (TargetCheckerTest ×5 incl. transport seam + SSRF-blocked-never-probed;
   SwarmTaskWebhookTest ×3 incl. optional-dep no-op). Config trap noted:
   env values are IGNORED for keys absent from Config::defaults() (same
   family as B-005) — always register new keys there.

   **ALL PHASES P1-P16 ARE COMPLETE.** Next work = post-release follow-ups
   (see `Current phase` row) or new user direction.

DONE this cycle: **P12.0-P12.4** (pipeline re-sync; uk, be, de, **es** 100 %;
coverage 6/66) and **P11 complete** — admin
core, swarm admin, security desk (79 dead stubs purged), team RBAC + shared
saved views; before that P10 (notifications/webhooks/alerts/comments/teams),
P8.6 abuse reporting, P7 saved views, P4 polish.

Process rules (checkpoint ritual — also in the task prompt's ENV section):
1. `cd` into the repo && `git log --oneline | head -40` + read WORKLOG.md tail.
2. Read §3, pick the first ⬜/🟡 phase; read §5.1/§5.2 before touching those streams.
3. Implement in chunks ≤200 lines per write → `php tests/run.php` → fix → `php bin/iya lint`.
4. Checkpoint EVERY block: atomic `git commit` → update this file (§0 checkpoint row,
   §2/§3 statuses, §8, §9) → append WORKLOG.md → `bash scripts/sync.sh`.
5. **If the context window is nearly exhausted: checkpoint FIRST, then continue.**
6. **NEW (user directive 2026-10-17, post-crash): every checkpoint ALSO builds the
   `P12` archive at the Workspace root** — `bash scripts/pack_p12.sh` (writes
   `/home/user/P12.tar.gz` + refreshes `/home/user/P12-MANIFEST.md`). Unlike
   `backup/versions/*` it IS overwritten each time: it is the "everything done so
   far" snapshot for the current phase. Manifest = `/home/user/P12-MANIFEST.md`.
7. **After a sandbox reset** (php missing, `.git` gone): `apt-get update &&
   apt-get install -y php-cli php-sqlite3 php-mbstring php-xml php-curl php-intl
   php-zip` → `php tests/run.php` (expect 434/2949/0) → `git init` + baseline
   commit → continue from §9. Verified 2026-10-17 (commit `ce0e15f`).
8. **NEW (user directive 2026-10-17): after EVERY finished locale run
   `bash scripts/backup_i18n.sh <code>`** — duplicates `_base.en.json`,
   `_platform.<code>.json` and the generated `<code>.json` into
   `<workspace>/backup/i18n/` (durable) and `/mnt/oss/iya-i18n/` (best effort).
   Run it right after the locale's commit, before anything else.
9. **NEW (user directive 2026-10-17): every 10 full-coverage locales the same
   script auto-creates a milestone archive** — it calls `sync.sh` (versioned
   tarball in `backup/versions/`, never overwritten) and mirrors the newest
   tarball + `P12.tar.gz` to `/mnt/oss/iya-platform-backups/`. Idempotent via
   `backup/i18n/.milestone-N` markers; force with `--milestone`.
   **Snapshot budget (ADR-015, ≤128 MB for /home/user): `backup/versions/`
   keeps ONLY the newest tarball locally — after verifying a copy exists in
   `/mnt/oss/iya-platform-backups/`, older local versioned tarballs are
   deleted (full per-commit history lives in /mnt/oss). Check `du -sh
   /home/user` at every locale checkpoint; trim the same way if over ~120 MB.**
10. **NEW (user directive 2026-10-18, autonomous 8-h run): after EVERY step
    (each i18n batch write, each code change + commit) run
    `bash scripts/pack_p12_quick.sh`** — rotates P12.tar.gz → P12_PRE.tar.gz
    and rebuilds P12 fast (no test gate). Full `pack_p12.sh` + `sync.sh` +
    `backup_i18n.sh` + REWORK-STATE/WORKLOG updates still happen at every
    locale checkpoint. See ADR-016.
11. **NEW (user directive 2026-10-18, post-reset #2 session): every round ends
    with a remaining-work report** — in the chat reply AND in the WORKLOG
    entry: (a) P12 locales remaining N/66 + which ones, (b) P12 % remaining,
    (c) overall roadmap % remaining (phases P1-P11 done; P12 = coverage/66;
    P13-P16 pending → overall remaining ≈ (0.38·P12weight + P13..P16)/total,
    current formula: done=11+coverage/66 of 16 phases → rest = 1 − done/16).
    Keep the numbers honest and reproducible from `build_i18n.php --check`.

Sandbox path note: the display path `/home/user` is mapped by the tool to the real
workspace root; runtime scripts must DERIVE paths (like `scripts/sync.sh` does from
its own location) instead of hardcoding them — hardcoded literals inside file
contents are NOT remapped when the file is executed.

### Recovery checklist (lost context / fresh sandbox)
```bash
# 1) The tree itself (snapshot-persisted):
ls /home/user/iya-platform/REWORK-STATE.md /home/user/iya-platform/WORKLOG.md
# 2) If /home/user/iya-platform is missing/older — restore from the durable backup:
ls -t /home/user/backup/versions/*.tar.gz | head -1
mkdir -p /home/user/iya-platform && tar -xzf "$(ls -t /home/user/backup/versions/*.tar.gz | head -1)" -C /home/user/iya-platform
# 3) Environment (does NOT survive resets):
apt-get update -qq && apt-get install -y -qq php-cli php-sqlite3 php-mbstring php-xml php-curl php-intl php-zip
# 4) .git may be lost in snapshots — re-init and commit a baseline:
cd /home/user/iya-platform && git init -q 2>/dev/null; git add -A && git commit -qm "restore: baseline $(date -u +%F)" || true
# 5) Health check (must match the "Last checkpoint" row in §0):
php bin/iya migrate && php tests/run.php | tail -2
# 6) Read WORKLOG.md (tail) + §9 below, continue from the first ⬜/🟡 phase.
# 7) Emergency only: /mnt/oss/*.tar.gz snapshots contain history.json — read the
#    LAST messages to find the exact interruption point (do not read the whole file).
```
