diff --git a/.gitignore b/.gitignore
index aefcb23..06882b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@
.claude/
.agent/
.deepcode/
+.redteam/
# ────────────────────────────────────────────────────────────────────────────
# Python
@@ -81,6 +82,8 @@ coverage-*.json
.tox/
.nox/
mutmut*
+!mutmut_pytest.ini
+# ↑ Keep mutmut_pytest.ini tracked: isolated pytest config for the mutation session.
.mutmut-cache/
mutants/
diff --git a/CHANGELOG.it.md b/CHANGELOG.it.md
new file mode 100644
index 0000000..e2b30e9
--- /dev/null
+++ b/CHANGELOG.it.md
@@ -0,0 +1,133 @@
+
+
+
+# Registro delle modifiche
+
+Tutte le modifiche rilevanti a Zenzic sono documentate qui.
+Il formato segue [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+Le versioni seguono il [Semantic Versioning](https://semver.org/).
+
+---
+
+## [Non rilasciato]
+
+## [0.5.0a4] — 2026-04-08 — Il Sentinel Indurito: Sicurezza & Integrità
+
+> **Rilascio Alpha 4.** Quattro vulnerabilità confermate chiuse (ZRT-001–004), tre
+> nuovi pilastri di hardening aggiunti (Sentinella di Sangue, Integrità del Grafo,
+> Scudo Esadecimale), e piena parità documentale bilingue raggiunta. In attesa di
+> revisione manuale prima della promozione a Release Candidate.
+>
+> Branch: `fix/sentinel-hardening-v0.5.0a4`
+
+### Aggiunto
+
+- **Integrità del grafo — rilevamento link circolari.** Zenzic ora pre-calcola
+ un registro dei cicli (Fase 1.5) tramite ricerca depth-first iterativa (Θ(V+E))
+ sul grafo dei link interni risolti. Ogni link il cui target appartiene a un ciclo
+ emette un finding `CIRCULAR_LINK` con severità `info`. I link di navigazione
+ reciproca (A ↔ B) sono una struttura valida della documentazione; il finding è
+ puramente informativo — non influisce mai sugli exit code in modalità normale o
+ `--strict`. O(1) per query in Phase 2. Le Ghost Route (URL canonici generati da
+ plugin senza file sorgente fisico) sono correttamente escluse dal grafo dei cicli.
+
+- **`INTERNAL_GLOSSARY.toml`** — registro bilingue EN↔IT dei termini tecnici
+ (15 voci) per un vocabolario coerente tra documentazione inglese e italiana. Copre
+ i concetti principali: Porto Sicuro, Rotta Fantasma, Mappa del Sito Virtuale,
+ Motore a Due Passaggi, Scudo, Sentinella di Sangue e altri. Mantenuto da S-0.
+ Tutti i termini con `stable = true` richiedono un ADR prima della rinomina.
+
+- **Parità documentale bilingue.** `docs/checks.md` e `docs/it/checks.md` aggiornati
+ con le sezioni Sentinella di Sangue, Link Circolari e Scudo Esadecimale.
+ `CHANGELOG.it.md` creato. Piena parità EN↔IT applicata per il Protocollo di
+ Parità Bilingue.
+
+### ⚠️ Sicurezza
+
+- **Sentinella di Sangue — classificazione degli attraversamenti di percorso (Exit Code 3).**
+ `check links` e `check all` ora classificano i finding di path-traversal per
+ intenzione. Un href che esce da `docs/` e si risolve in una directory di sistema
+ del SO (`/etc/`, `/root/`, `/var/`, `/proc/`, `/sys/`, `/usr/`) viene classificato
+ come `PATH_TRAVERSAL_SUSPICIOUS` con severità `security_incident` e attiva
+ l'**Exit Code 3** — un nuovo exit code dedicato riservato alle sonde del sistema
+ host. L'Exit 3 ha priorità sull'Exit 2 (violazione credenziali) e non viene mai
+ soppresso da `--exit-zero`. Gli attraversamenti fuori confine ordinari (es.
+ `../../repo-adiacente/`) restano `PATH_TRAVERSAL` con severità `error` (Exit Code 1).
+
+- **Scudo Esadecimale — rilevamento di payload hex-encoded.**
+ Un nuovo pattern built-in dello Shield, `hex-encoded-payload`, rileva sequenze di
+ tre o più escape hex `\xNN` consecutive (`(?:\\x[0-9a-fA-F]{2}){3,}`). La soglia
+ `{3,}` evita falsi positivi sulle singole escape hex comuni nella documentazione
+ delle regex. I finding escono con codice 2 (Shield, non sopprimibile) e si
+ applicano a tutti i flussi di contenuto inclusi i blocchi di codice delimitati.
+
+- **[ZRT-001] Shield Blind Spot — Bypass YAML Frontmatter (CRITICO).**
+ `_skip_frontmatter()` veniva usato come sorgente di righe dello Shield,
+ scartando silenziosamente ogni riga nel blocco YAML `---` del file prima che
+ il motore regex girasse. Qualsiasi coppia chiave-valore (`aws_key: AKIA…`,
+ `github_token: ghp_…`) era invisibile allo Shield.
+ **Fix:** Il flusso Shield ora usa `enumerate(fh, start=1)` grezzo — ogni byte
+ del file viene scansionato. Il flusso contenuto usa ancora `_iter_content_lines()`
+ con salto del frontmatter per evitare falsi positivi da valori di metadati.
+ Architettura **Dual-Stream**.
+
+- **[ZRT-002] ReDoS + Deadlock ProcessPoolExecutor (ALTO).**
+ Un pattern `[[custom_rules]]` come `^(a+)+$` superava il controllo
+ `_assert_pickleable()` e veniva distribuito ai worker process senza timeout.
+ **Due difese aggiunte:**
+ — *Canary (prevenzione):* `_assert_regex_canary()` stress-testa ogni pattern
+ `CustomRule` sotto un watchdog `signal.SIGALRM` di 100 ms. I pattern ReDoS
+ sollevano `PluginContractError` prima della prima scansione.
+ — *Timeout (contenimento):* `ProcessPoolExecutor.map()` sostituito con
+ `submit()` + `future.result(timeout=30)`.
+
+- **[ZRT-003] Bypass Shield Split-Token — Offuscamento Tabelle Markdown (MEDIO).**
+ Il separatore `|` delle tabelle Markdown spezzava i token segreti su più celle.
+ **Fix:** Le righe di tabella vengono de-pipe prima della scansione Shield.
+
+- **[ZRT-004] Injection Path Traversal nei Link Reference (BASSO).**
+ Link reference con href malevoli potevano sfuggire alla sandbox `docs/`.
+ **Fix:** La validazione PATH_TRAVERSAL applicata ai link reference come ai link
+ inline.
+
+## [0.5.0a3] — 2026-03-28 — Il Sentinel: Plugin, Regole Adattive, Hooks Pre-commit
+
+> Branch: `feat/sentinel-v0.5.0a3`
+
+### Aggiunto
+
+- **Sistema Plugin** — `[[custom_rules]]` in `zenzic.toml` per regole regex
+ personalizzate. `PluginContractError` per la validazione contratto a boot.
+- **Regex Canary** — watchdog SIGALRM 100 ms per backtracking catastrofico.
+- **Hooks Pre-commit** — configurazione ufficiale per pipeline CI.
+- **UI Sentinel** — palette colori, reporter a griglia, output Sentinel rinnovato.
+
+## [0.5.0a1] — 2026-03-15 — Il Sentinel: Motore Adattivo delle Regole
+
+> Branch: `feat/sentinel-v0.5.0a1`
+
+### Aggiunto
+
+- **AdaptiveRuleEngine** — motore di analisi estensibile con Phase 3.
+- **Hybrid Adaptive Engine** — integrazione MkDocs + motore adattivo.
+- **Pannelli Sentinel** — output strutturato per tutti i controlli.
+
+## [0.4.0] — 2026-03-01 — Il Grande Disaccoppiamento
+
+> Branch: `feat/engine-decoupling`
+
+### Aggiunto
+
+- **Factory entry-point dinamica** — `--engine` CLI flag; protocollo
+ `has_engine_config`.
+- **InMemoryPathResolver** — resolver agnostico rispetto al motore.
+- **Tower of Babel Guard** — fallback i18n per ancora mancante nella locale.
+
+## [0.3.0] — 2026-02-15 — Two-Pass Pipeline
+
+### Aggiunto
+
+- **Two-Pass Engine** — Phase 1 (I/O parallelo) + Phase 2 (validazione O(1)).
+- **Virtual Site Map (VSM)** — proiezione logica del sito renderizzato.
+- **Shield** — rilevamento segreti, Stream Dual, exit code 2.
+- **Validazione anchor cross-lingua** — Tower of Babel Guard.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae7bbd7..865ad14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,196 @@ Versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
-## [0.5.0a3] — 2026-04-03 — The Sentinel: Aesthetic Sprint, Parallel Anchors & Agnostic Target
+## [0.5.0a4] — 2026-04-08 — The Hardened Sentinel: Security & Integrity
+
+> **Alpha 4 Release.** Four confirmed vulnerabilities closed (ZRT-001–004), three
+> new hardening pillars added (Blood Sentinel, Graph Integrity, Hex Shield), and
+> full bilingual documentation parity achieved. Pending manual review before
+> Release Candidate promotion.
+>
+> Branch: `fix/sentinel-hardening-v0.5.0a4`
+
+### Added
+
+- **Graph Integrity — circular link detection.** Zenzic now pre-computes a cycle
+ registry (Phase 1.5) via iterative depth-first search (Θ(V+E)) over the resolved
+ internal link graph. Any link whose target belongs to a cycle emits a `CIRCULAR_LINK`
+ finding at severity `info`. Mutual navigation links (A ↔ B) are valid documentation
+ structure and are expected; the finding is advisory only — it never affects exit
+ codes in normal or `--strict` mode. O(1) per-query in Phase 2. Ghost Routes
+ (plugin-generated canonical URLs without physical source files) are correctly
+ excluded from the cycle graph and cannot produce false positives.
+
+- **`INTERNAL_GLOSSARY.toml`** — bilingual EN↔IT term registry (15 entries) for
+ consistent technical vocabulary across English and Italian documentation. Covers
+ core concepts: Safe Harbor, Ghost Route, Virtual Site Map, Two-Pass Engine, Shield,
+ Blood Sentinel, and more. Maintained by S-0. All terms marked `stable = true`
+ require an ADR before renaming.
+
+- **Bilingual documentation parity.** `docs/checks.md` and `docs/it/checks.md`
+ updated with Blood Sentinel, Circular Links, and Hex Shield sections.
+ `CHANGELOG.it.md` created. Full English–Italian parity enforced per the
+ Bilingual Parity Protocol.
+
+### ⚠️ Security
+
+- **Blood Sentinel — system-path traversal classification (Exit Code 3).**
+ `check links` and `check all` now classify path-traversal findings by intent.
+ An href that escapes `docs/` and resolves to an OS system directory (`/etc/`,
+ `/root/`, `/var/`, `/proc/`, `/sys/`, `/usr/`) is classified as
+ `PATH_TRAVERSAL_SUSPICIOUS` with severity `security_incident` and triggers
+ **Exit Code 3** — a new, dedicated exit code reserved for host-system probes.
+ Exit 3 takes priority over Exit 2 (credential breach) and is never suppressed
+ by `--exit-zero`. Plain out-of-bounds traversals (e.g. `../../sibling-repo/`)
+ remain `PATH_TRAVERSAL` at severity `error` (Exit Code 1).
+
+- **Hex Shield — hex-encoded payload detection.**
+ A new built-in Shield pattern `hex-encoded-payload` detects runs of three or
+ more consecutive `\xNN` hex escape sequences (`(?:\\x[0-9a-fA-F]{2}){3,}`).
+ The `{3,}` threshold avoids false positives on single hex escapes common in
+ regex documentation. Findings exit with code 2 (Shield, non-suppressible)
+ and apply to all content streams including fenced code blocks.
+
+- **[ZRT-001] Shield Blind Spot — YAML Frontmatter Bypass (CRITICAL).**
+ `_skip_frontmatter()` was used as the Shield's line source, silently
+ discarding every line in a file's YAML `---` block before the regex
+ engine ran. Any key-value pair (`aws_key: AKIA…`, `github_token: ghp_…`)
+ was invisible to the Shield and would have exited `zenzic check all` with
+ code `0`.
+ **Fix:** The Shield stream now uses a raw `enumerate(fh, start=1)` —
+ every byte of the file is scanned. The content stream (ref-def harvesting)
+ still uses `_iter_content_lines()` with frontmatter skipping to avoid
+ false-positive link findings from metadata values. This is the
+ **Dual-Stream** architecture described in the remediation directives.
+ *Exploit PoC confirmed via live script: 0 findings before fix, correct
+ detection of AWS / OpenAI / Stripe / GitHub tokens after fix.*
+
+- **[ZRT-002] ReDoS + ProcessPoolExecutor Deadlock (HIGH).**
+ A `[[custom_rules]]` pattern like `^(a+)+$` passed the eager
+ `_assert_pickleable()` check (pickle is blind to regex complexity) and
+ was distributed to worker processes. The `ProcessPoolExecutor` had no
+ timeout: any worker hitting a ReDoS-vulnerable pattern on a long input
+ line hung permanently, blocking the entire CI pipeline.
+ **Two defences added:**
+ — *Canary (prevention):* `_assert_regex_canary()` stress-tests every
+ `CustomRule` pattern against three canary strings (`"a"*30+"b"`, etc.)
+ under a `signal.SIGALRM` watchdog of 100 ms at `AdaptiveRuleEngine`
+ construction time. ReDoS patterns raise `PluginContractError` before the
+ first file is scanned. (Linux/macOS only; silently skipped on Windows.)
+ — *Timeout (containment):* `ProcessPoolExecutor.map()` replaced with
+ `submit()` + `future.result(timeout=30)`. A timed-out worker produces a
+ `Z009: ANALYSIS_TIMEOUT` `RuleFinding` instead of hanging the scan.
+ The new `_make_timeout_report()` and `_make_error_report()` helpers
+ ensure clean error surfacing in the standard findings UI.
+ *Exploit PoC confirmed: `^(a+)+$` on `"a"*30+"b"` timed out in 5 s;
+ both defences independently prevent scan lock-up.*
+
+- **[ZRT-003] Split-Token Shield Bypass — Markdown Table Obfuscation (MEDIUM).**
+ The Shield's `scan_line_for_secrets()` ran each raw line through the
+ regex patterns once. A secret fragmented across backtick spans and a
+ string concatenation operator (`` `AKIA` + `1234567890ABCDEF` ``) inside
+ a Markdown table cell was never reconstructed, so the 20-character
+ contiguous `AKIA[0-9A-Z]{16}` pattern never matched.
+ **Fix:** New `_normalize_line_for_shield()` pre-processor in `shield.py`
+ unwraps backtick spans, removes concatenation operators, and collapses
+ table pipes before scanning. Both the raw line and the normalised form are
+ scanned; a `seen` set prevents duplicate findings when both forms match.
+
+### Changed
+
+- **[ZRT-004] Context-Aware VSM Resolution — `VSMBrokenLinkRule` (MEDIUM).**
+ `_to_canonical_url()` was a `@staticmethod` without access to the source
+ file's directory. Relative hrefs containing `..` segments (e.g.
+ `../../c/target.md` from `docs/a/b/page.md`) were resolved as if they
+ originated from the docs root, producing false negatives: broken relative
+ links in nested files were silently passed.
+ **Fix:** New `ResolutionContext` dataclass (`docs_root: Path`,
+ `source_file: Path`) added to `rules.py`. `BaseRule.check_vsm()` and
+ `AdaptiveRuleEngine.run_vsm()` accept `context: ResolutionContext | None`
+ (default `None` — fully backwards-compatible). `_to_canonical_url()` is
+ now an instance method that resolves `..` segments via `os.path.normpath`
+ relative to `context.source_file.parent` when context is provided, then
+ re-maps to a docs-relative posix path before the clean-URL transformation.
+ Paths that escape `docs_root` return `None` (Shield boundary respected).
+
+- **[GA-1] Telemetry / Executor Worker Count Synchronisation.**
+ `ProcessPoolExecutor(max_workers=workers)` used the raw `workers` sentinel
+ (may be `None`) while the telemetry reported `actual_workers` (always an
+ integer). Both now use `actual_workers`, eliminating the divergence.
+
+- **Stream Multiplexing** (`scanner.py`). `ReferenceScanner.harvest()`
+ now explicitly documents its two-stream design: **Shield stream** (all
+ lines, raw `enumerate`) and **Content stream** (`_iter_content_lines`,
+ frontmatter/fence filtered). Comments updated to make the architectural
+ intent visible to future contributors.
+
+- **[Z-SEC-002] Secure Breach Reporting Pipeline (Commit 2).**
+ Four structural changes harden the path from secret detection to CI output:
+
+ — *Breach Panel (`reporter.py`):* findings with `severity="security_breach"`
+ render as a dedicated high-contrast panel (red on white) positioned before
+ all other findings. Surgical caret underlines (`^^^^`) are positioned using
+ the `col_start` and `match_text` fields added to `SecurityFinding`.
+
+ — *Surgical Secret Masking — `_obfuscate_secret()`:* raw secret material is
+ never passed to Rich or CI log streams. The function partially redacts
+ credentials (first 4 + last 4 chars; full redaction for strings ≤ 8 chars)
+ and is the **sole authorised path** for rendering secret values in output.
+
+ — *Bridge Function — `_map_shield_to_finding()` (`scanner.py`):* a single
+ pure function is the only authorised conversion point between the Shield
+ detection layer and `SentinelReporter`. Extracted as a standalone function
+ so that mutation testing can target it directly and unambiguously.
+
+ — *Post-Render Exit 2 (`cli.py`):* the security hard-stop is now applied
+ **after** `reporter.render()`, guaranteeing the full breach panel is
+ visible in CI logs before the process exits with code 2.
+
+### Testing
+
+- **`tests/test_redteam_remediation.py`** — 25 new tests organised in four
+ classes, one per ZRT finding:
+ - `TestShieldFrontmatterCoverage` (4 tests) — verifies Shield catches
+ AWS, GitHub, and multi-pattern secrets inside YAML frontmatter; confirms
+ correct line-number reporting; guards against false positives on clean
+ metadata.
+ - `TestReDoSCanary` (6 tests) — verifies canary rejects classic `(a+)+`
+ and alternation-based `(a|aa)+` ReDoS patterns at engine construction;
+ confirms safe patterns pass; verifies non-`CustomRule` subclasses are
+ skipped.
+ - `TestShieldNormalizer` (8 tests) — verifies `_normalize_line_for_shield`
+ unwraps backtick spans, removes concat operators, collapses table pipes;
+ verifies `scan_line_for_secrets` catches split-token AWS key; confirms
+ deduplication prevents double-emit when raw and normalised both match.
+ - `TestVSMContextAwareResolution` (7 tests) — verifies multi-level `..`
+ resolution from nested dirs, single `..` from subdirs, absent-from-VSM
+ still emits Z001, path-traversal escape returns no false Z001, backwards
+ compatibility without context, `index.md` directory mapping, and
+ `run_vsm` context forwarding.
+- **`tests/test_rules.py`** — `_BrokenVsmRule.check_vsm()` updated to
+ accept the new `context=None` parameter (API compatibility fix).
+- **731 tests pass.** Zero regressions. `pytest --tb=short` — all green.
+
+- **`TestShieldReportingIntegrity` — Mutation Gate (Commit 3, Z-TEST-003).**
+ Three mandatory tests serving as permanent Mutation Gate guards for the
+ security reporting pipeline:
+ - *The Invisible:* `_map_shield_to_finding()` must always emit
+ `severity="security_breach"` — a downgrade to `"warning"` is caught
+ immediately (`assert 'warning' == 'security_breach'`).
+ - *The Amnesiac:* `_obfuscate_secret()` must never return the raw secret
+ — removing the redaction logic is caught immediately
+ (`assert raw_key not in output`).
+ - *The Silencer:* `_map_shield_to_finding()` must never return `None` —
+ a bridge function that discards findings is caught immediately
+ (`assert result is not None`).
+
+ **Manual verification (The Sentinel's Trial):** all three mutants were
+ applied by hand and confirmed killed. `mutmut` v3 automatic reporting was
+ blocked by an editable-install interaction (see `mutmut_pytest.ini`); manual
+ verification accepted per Architecture Lead authorisation (Z-TEST-003).
+ **28 tests in `test_redteam_remediation.py`, all green.**
+
+## [0.5.0a4] — 2026-04-03 — The Sentinel: Aesthetic Sprint, Parallel Anchors & Agnostic Target
> **Sprint 13 + 14 + 15.** Three tracks delivered in one tag.
> Track A — Performance & SDK: deterministic two-phase anchor validation, `zenzic.rules` public
@@ -1397,7 +1586,7 @@ It has been superseded by the 0.5.x stabilization cycle.
[Unreleased]: https://github.com/PythonWoods/zenzic/compare/v0.5.0a3...HEAD
-[0.5.0a3]: https://github.com/PythonWoods/zenzic/compare/v0.5.0a2...v0.5.0a3
+[0.5.0a4]: https://github.com/PythonWoods/zenzic/compare/v0.5.0a2...v0.5.0a3
[0.5.0a2]: https://github.com/PythonWoods/zenzic/compare/v0.5.0a1...v0.5.0a2
[0.5.0a1]: https://github.com/PythonWoods/zenzic/compare/v0.4.0-rc5...v0.5.0a1
[0.4.0-rc5]: https://github.com/PythonWoods/zenzic/compare/v0.4.0-rc4...v0.4.0-rc5
diff --git a/CITATION.cff b/CITATION.cff
index 8339911..cd6665a 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -15,7 +15,7 @@ abstract: >
scanner (the Shield). Built on pure functional principles in Python 3.11+, it operates
source-first — no build framework required — and integrates with any Markdown-based
documentation system via a plugin adapter protocol.
-version: 0.5.0a3
+version: 0.5.0a4
date-released: 2026-04-03
url: "https://zenzic.pythonwoods.dev/"
repository-code: "https://github.com/PythonWoods/zenzic"
diff --git a/CONTRIBUTING.it.md b/CONTRIBUTING.it.md
index 9919de2..294fe55 100644
--- a/CONTRIBUTING.it.md
+++ b/CONTRIBUTING.it.md
@@ -132,19 +132,344 @@ Aggiungere validazioni su motore third-party richiede lo sforzo di replicare app
### Portabilità & Integrità i18n
-Zenzic offre standard compatibile e out/box di adozione i18n implementata `mkdocs-static-i18n`:
+Zenzic supporta entrambe le strategie i18n utilizzate da `mkdocs-static-i18n`:
-- **Modalità suffisso** (`filename.locale.md`) — La traduzione resta vicina, posizionata all'atto in pari estensione al dominio gốc/sorgente di lavoro con cui simmetricamente convive tramite risoluzioni asset e anchor match-tree paritari. Acquisizione locale prefisso si precompila esulante extra setups.
-- **Modalità cartella** (`docs/it/filename.md`) — Subdirectory appositamente confinate ed isolate per i path non-default. MkDocsAdapter ricompatterà l'albero d'orfanità e asset integrando referenze da `zenzic.toml` via property config in locale fallback configuration property array su `[build_context]` in assenza YAML sorgente main configurato su `mkdocs.yml`.
+- **Modalità suffisso** (`filename.locale.md`) — I file tradotti sono affiancati agli originali alla stessa profondità di directory. I percorsi degli asset relativi sono simmetrici tra le lingue. Zenzic rileva automaticamente i suffissi locale dai nomi dei file, senza alcuna configurazione aggiuntiva.
+- **Modalità cartella** (`docs/it/filename.md`) — I locale non predefiniti risiedono in una directory di primo livello. Il rilevamento degli asset e degli orfani è gestito da `MkDocsAdapter` tramite `[build_context]` in `zenzic.toml`. In assenza di `zenzic.toml`, Zenzic legge la configurazione locale direttamente da `mkdocs.yml`.
-**Proibizione Link Assoluti**
-Zenzic scarta rigorosamente le reference con inizializzazione `/` per non vincolarsi perentoriamente al root-doman root. Nel momento di migrazione verso public directory o hosting diramata in namespace specifici origin site (e.g. `/docs`), una reference index base come `[Home](/docs/assets/logo.png)` imploderebbe. Fai valere link interni come percorsi parent path (e.g. `../assets/logo.png`) incrementando portabilità del progetto e documentazione a lungo termine offline/online.
+**Divieto di Link Assoluti**
+Zenzic rifiuta qualsiasi link interno che inizi con `/`. I percorsi assoluti presuppongono che il sito sia ospitato alla radice del dominio: se la documentazione viene servita da una sottodirectory (es. `https://example.com/docs/`), un link come `/assets/logo.png` si risolve in `https://example.com/assets/logo.png` (404), non nell'asset desiderato. Usa percorsi relativi (`../assets/logo.png`) per garantire la portabilità indipendentemente dall'ambiente di hosting.
+
+### Sovranità della VSM
+
+Qualsiasi controllo di esistenza su una risorsa interna (pagina, immagine, ancora) **deve** interrogare la Virtual Site Map — mai il filesystem.
+
+**Perché:** La VSM include le **Ghost Route** — URL canonici generati da plugin di build (es. `reconfigure_material: true`) che non hanno un file `.md` fisico su disco. Una chiamata a `Path.exists()` restituisce `False` per una Ghost Route. La VSM restituisce `REACHABLE`. La VSM è l'oracolo; il filesystem non lo è.
+
+**Violazione di Grado 1:** Usare `os.path.exists()`, `Path.is_file()`, o qualsiasi altra probe al filesystem per validare un link interno è una violazione architetturale di Grado 1. Le PR che contengono questo pattern saranno chiuse senza revisione.
+
+```python
+# ❌ Violazione Grado 1 — interroga il filesystem, manca le Ghost Route
+if (docs_root / relative_path).exists():
+ ...
+
+# ✅ Corretto — interroga la VSM
+route = vsm.get(canonical_url)
+if route and route.status == "REACHABLE":
+ ...
+```
+
+Correlato: vedi `docs/arch/vsm_engine.md` — *Catalogo degli Anti-Pattern* per l'elenco completo delle chiamate al filesystem vietate nelle regole.
+
+### Ghost Route Awareness
+
+Le regole di rilevamento orfani devono rispettare le route contrassegnate come Ghost Route nella VSM. Una Ghost Route non è un orfano — è una route che il motore di build genera al momento della build da un plugin, senza un file sorgente `.md`.
+
+**Azione:** Ogni nuova regola di scansione globale che esegue il rilevamento orfani deve accettare un parametro costruttore `include_ghosts: bool = False`. Quando `include_ghosts=False` (il default), le route con `status == "ORPHAN_BUT_EXISTING"` generate da un meccanismo Ghost Route devono essere escluse dai finding.
+
+```python
+class MiaRegolaOrfani(BaseRule):
+ def __init__(self, include_ghosts: bool = False) -> None:
+ self._include_ghosts = include_ghosts
+
+ def check_vsm(self, file_path, text, vsm, anchors_cache, context=None):
+ for url, route in vsm.items():
+ if route.status == "ORPHAN_BUT_EXISTING":
+ # Salta gli orfani derivati da Ghost Route a meno che non siano inclusi esplicitamente
+ if not self._include_ghosts and _is_ghost_derived(route):
+ continue
+ ...
+```
+
+### Protocollo di Scoperta della Radice (PSR)
+
+`find_repo_root()` è il singolo punto di ingresso attraverso cui Zenzic stabilisce il confine del suo **Workspace**. Tutto il resto — costruzione della VSM, risoluzione dei link, caricamento della configurazione — dipende dal percorso che restituisce. Trattalo come infrastruttura portante.
+
+#### L'Autorità della Radice
+
+Zenzic non analizza file in isolamento. Analizza un **Workspace**: un insieme delimitato di file le cui relazioni — link, ancore, voci di nav, stato orfano — sono significative solo relativamente a una radice condivisa. La Radice è la parete esterna invalicabile della VSM. Un controllo che sfugge a questa parete non è un controllo Zenzic; è una vulnerabilità.
+
+#### Ereditarietà dello Standard — Perché `.git`?
+
+`.git` è usato come proxy della volontà dichiarata dall'utente. La presenza di una directory `.git` significa che l'utente ha già stabilito un confine VCS per questo progetto. Zenzic eredita quel confine invece di inventarne uno proprio. Questo mantiene Zenzic forward-compatible con future esclusioni basate su `.gitignore`: automatizza l'esclusione di `site/`, `dist/` e altri artefatti generati già presenti nella maggior parte dei file `.gitignore`.
+
+`zenzic.toml` è il marcatore di fallback per ambienti senza VCS (es. un progetto solo di documentazione, un container CI con checkout superficiale). Se `zenzic.toml` esiste, Zenzic usa la sua directory come radice — senza bisogno di `.git`.
+
+#### Sicurezza per Opt-in — Il Default Deve Essere Sicuro
+
+Il comportamento di fallimento per impostazione predefinita è intenzionale. Un'invocazione di `zenzic check all` da `/home/utente/` senza alcun marcatore di radice in tutta la catena degli antenati solleva `RuntimeError` immediatamente, prima che venga letto un singolo file. Questa non è una mancanza di usabilità — è una **garanzia di sicurezza**. L'alternativa (default silenzioso alla CWD o alla radice del filesystem) esporrebbe Zenzic all'Indicizzazione Massiva Accidentale: scansione di migliaia di file non correlati, produzione di risultati privi di senso e potenziale perdita di informazioni attraverso confini di progetto in ambienti CI.
+
+**La mutazione di questo default richiede approvazione dell'Architecture Lead.** Una PR che cambia `fallback_to_cwd=False` in `True` in qualsiasi call site diverso da `init` è una violazione di sicurezza di Grado-1 e verrà chiusa senza revisione.
+
+#### L'Eccezione di Bootstrap
+
+Solo `zenzic init` è esente dal requisito rigoroso della radice. Il suo scopo è *creare* il marcatore di radice — richiedere che il marcatore pre-esista sarebbe il Paradosso di Bootstrap (ZRT-005). L'esenzione è codificata come parametro keyword-only affinché il call site sia auto-documentante e verificabile per ispezione:
+
+```python
+# ✅ Consentito solo in cli.py::init — crea un nuovo perimetro da zero
+repo_root = find_repo_root(fallback_to_cwd=True)
+
+# ✅ Tutti gli altri comandi — applicazione rigorosa del perimetro, solleva fuori da un repo
+repo_root = find_repo_root()
+```
+
+Aggiungere `fallback_to_cwd=True` a qualsiasi comando diverso da `init` richiede un Architecture Decision Record che spieghi perché quel comando necessita di accesso senza perimetro.
+
+Vedi [ADR 003](docs/adr/003-discovery-logic.md) per la motivazione completa e la storia della modifica ZRT-005.
+
+---
## Sicurezza & Conformità
-- **Sicurezza Piena:** Prevenire manipolazioni estese con `PathTraversal`. Verificare il bypass con Pathing Check su codebase in logica risolvitiva nativa `core`.
-- **Parità Bilingua:** Aggiornamenti standard devono fluire nella traduzione cartelle come logica copy-mirror da `docs/*.md` in cartellatura folder-mode a `docs/it/*.md`.
-- **Integrità Base Asset:** Badges documentate presso file risorsa SVG (e.g. `docs/assets/brand/`) non andranno rimosse asincronizzate ai parametri calcolo punteggi app logic score.
+- **Sicurezza Prima di Tutto:** Qualsiasi nuova risoluzione di percorso DEVE essere testata contro il Path Traversal. Usa la logica `PathTraversal` da `core`.
+- **Parità Bilingue:** Ogni aggiornamento alla documentazione DEVE essere riflesso sia nei file `docs/*.md` che nei corrispondenti `docs/it/*.md` in modalità folder.
+- **Integrità degli Asset:** Assicurati che i badge SVG in `docs/assets/brand/` siano aggiornati se la logica di scoring cambia.
+
+---
+
+## Lo Scudo e il Canarino
+
+Questa sezione documenta le **quattro obbligazioni di sicurezza** che si applicano a
+ogni PR che tocca `src/zenzic/core/`. Una PR che risolve un bug senza soddisfare
+tutte e quattro verrà rifiutata dal Responsabile Architettura.
+
+Queste regole esistono perché l'analisi di sicurezza v0.5.0a3 (2026-04-04) ha
+dimostrato che quattro scelte di design individualmente ragionevoli — ciascuna
+corretta in isolamento — si sono composte in quattro distinti vettori di attacco.
+Vedi `docs/internal/security/shattered_mirror_report.md` per il post-mortem completo.
+
+---
+
+### Obbligazione 1 — La Tassa di Sicurezza (Timeout Worker)
+
+Ogni PR che modifica l'uso di `ProcessPoolExecutor` in `scanner.py` deve
+preservare la chiamata `future.result(timeout=_WORKER_TIMEOUT_S)`. Il timeout
+corrente è **30 secondi**.
+
+**Cosa significa:**
+
+```python
+# ✅ Forma richiesta — usa sempre submit() + result(timeout=...)
+futures_map = {executor.submit(_worker, item): item[0] for item in work_items}
+for fut, md_file in futures_map.items():
+ try:
+ raw.append(fut.result(timeout=_WORKER_TIMEOUT_S))
+ except concurrent.futures.TimeoutError:
+ raw.append(_make_timeout_report(md_file)) # finding Z009
+
+# ❌ Vietato — si blocca indefinitamente su ReDoS o worker in deadlock
+raw = list(executor.map(_worker, work_items))
+```
+
+**Il finding Z009** (`ANALYSIS_TIMEOUT`) non è un crash. È un finding strutturato
+che appare nell'interfaccia del report standard. Un worker che va in timeout non
+interrompe la scansione — il coordinatore continua con i worker rimanenti.
+
+**Se la tua modifica richiede naturalmente un timeout più lungo** (es. una nuova
+regola esegue calcoli costosi), aumenta `_WORKER_TIMEOUT_S` con un commento che
+spiega il costo e un benchmark che dimostra l'input peggiore.
+
+---
+
+### Obbligazione 2 — Il Protocollo Regex-Canary
+
+Ogni voce `[[custom_rules]]` che specifica un `pattern` è soggetta al
+**Regex-Canary**, uno stress test basato su POSIX `SIGALRM` che viene eseguito
+al momento della costruzione di `AdaptiveRuleEngine`.
+
+**Come funziona il canary:**
+
+```python
+# _assert_regex_canary() in rules.py — eseguito automaticamente per ogni CustomRule
+_CANARY_STRINGS = (
+ "a" * 30 + "b", # trigger classico (a+)+
+ "A" * 25 + "!", # variante maiuscola
+ "1" * 20 + "x", # variante numerica
+)
+_CANARY_TIMEOUT_S = 0.1 # 100 ms
+```
+
+Il canary applica ciascuna delle tre stringhe al metodo `check()` della regola
+sotto un watchdog di 100 ms. Se il pattern non si completa entro 100 ms su
+qualsiasi di queste stringhe, il motore solleva `PluginContractError` prima
+che la scansione inizi.
+
+**Testare il pattern contro il canary prima di committare:**
+
+```python
+from pathlib import Path
+from zenzic.core.rules import CustomRule, _assert_regex_canary
+from zenzic.core.exceptions import PluginContractError
+
+rule = CustomRule(
+ id="MIA-001",
+ pattern=r"il-tuo-pattern-qui",
+ message="Trovato.",
+ severity="warning",
+)
+
+try:
+ _assert_regex_canary(rule)
+ print("✅ Canary passato — il pattern è sicuro per la produzione")
+except PluginContractError as e:
+ print(f"❌ Canary fallito — rischio ReDoS rilevato:\n{e}")
+```
+
+Oppure dalla shell:
+
+```bash
+uv run python -c "
+from zenzic.core.rules import CustomRule, _assert_regex_canary
+r = CustomRule(id='T', pattern=r'IL_TUO_PATTERN', message='.', severity='warning')
+_assert_regex_canary(r)
+print('sicuro')
+"
+```
+
+**Pattern da evitare** (trigger di backtracking catastrofico):
+
+| Pattern | Perché pericoloso |
+|---------|------------------|
+| `(a+)+` | Quantificatori annidati — percorsi esponenziali |
+| `(a\|aa)+` | Alternazione con sovrapposizione |
+| `(a*)*` | Star annidato — match vuoti infiniti |
+| `.+foo.+bar` | Multi-wildcard greedy con suffisso |
+
+**Pattern sempre sicuri:**
+
+| Pattern | Note |
+|---------|------|
+| `TODO` | Match letterale, O(n) |
+| `^(BOZZA\|WIP):` | Alternazione ancorata, O(1) per posizione |
+| `[A-Z]{3}-\d+` | Classi di caratteri limitate |
+| `\bfoo\b` | Ancorato a word-boundary |
+
+**Nota piattaforma:** `_assert_regex_canary()` usa `signal.SIGALRM`, disponibile
+solo sui sistemi POSIX (Linux, macOS). Su Windows, il canary è un no-op. Il timeout
+del worker (Obbligazione 1) è il backstop universale.
+
+**Overhead del canary:** Misurato a **0,12 ms** per costruzione del motore con 10
+regole sicure (mediana su 20 iterazioni). È un costo una-tantum all'avvio della
+scansione, ben entro il budget accettabile della "Tassa di Sicurezza".
+
+---
+
+### Obbligazione 3 — L'Invariante Dual-Stream dello Shield
+
+Lo stream Shield e lo stream Contenuto in `ReferenceScanner.harvest()` non devono
+**mai condividere un generatore**. Questa è la lezione architetturale di ZRT-001.
+
+```python
+# ✅ CORRETTO — generatori indipendenti, contratti di filtraggio indipendenti
+with file_path.open(encoding="utf-8") as fh:
+ for lineno, line in enumerate(fh, start=1): # Shield: TUTTE le righe
+ list(scan_line_for_secrets(line, file_path, lineno))
+
+for lineno, line in _iter_content_lines(file_path): # Contenuto: filtrato
+ ...
+
+# ❌ VIETATO — condividere un generatore fa cadere il frontmatter dallo Shield
+with file_path.open(encoding="utf-8") as fh:
+ shared = _skip_frontmatter(fh)
+ for lineno, line in shared:
+ list(scan_line_for_secrets(...)) # ← cieco al frontmatter
+ for lineno, line in shared: # ← già esaurito
+ ...
+```
+
+**Performance Shield:** La doppia scansione (riga grezza + normalizzata) opera a
+circa **235.000 righe/secondo** (misurato: mediana 12,74 ms per 3.000 righe su
+20 iterazioni). Il normalizzatore aggiunge un passaggio per riga, ma il set `seen`
+previene finding duplicati, mantenendo l'output deterministico.
+
+Se una PR fa refactoring di `harvest()` e il benchmark CI scende sotto **100.000
+righe/secondo**, rifiutare e investigare prima del merge.
+
+---
+
+### Obbligazione 4 — Mutation Score ≥ 90% per le Modifiche Core
+
+Ogni PR che modifica `src/zenzic/core/` deve mantenere o migliorare il mutation
+score sul modulo interessato. La baseline attuale per `rules.py` è **86,7%**
+(242/279 mutanti uccisi).
+
+L'obiettivo per rc1 è **≥ 90%**. Una PR che aggiunge una nuova regola o modifica
+la logica di rilevamento senza uccidere i mutanti corrispondenti sarà rifiutata.
+
+**Eseguire il mutation testing:**
+
+```bash
+nox -s mutation
+```
+
+**Interpretare i mutanti sopravvissuti:**
+
+Non tutti i mutanti sopravvissuti sono equivalenti. Prima di contrassegnare un
+mutante come accettabile, verifica che:
+
+1. Il mutante cambia un comportamento osservabile (non è logicamente equivalente).
+2. Nessun test esistente cattura il mutante (è una lacuna genuina).
+3. Aggiungere un test per ucciderlo sarebbe ridondante o circolare.
+
+In caso di dubbio, aggiungi il test. La suite di mutation testing è un documento
+vivente del modello di minaccia della Sentinella.
+
+**Validazione pickle di ResolutionContext (Eager Validation 2.0):**
+
+`ResolutionContext` è un `@dataclass(slots=True)` con soli campi `Path`. `Path`
+è serializzabile con pickle dalla standard library. L'oggetto si serializza in
+157 byte. Tuttavia, se `ResolutionContext` acquisisce un campo non serializzabile
+(es. un file handle, un lock, una lambda), il motore parallelo fallirà in modo
+silenzioso.
+
+Per proteggersi da questo, qualsiasi PR che aggiunge un campo a `ResolutionContext`
+deve includere:
+
+```python
+# In tests/test_redteam_remediation.py (o in un test dedicato):
+def test_resolution_context_is_pickleable():
+ import pickle
+ ctx = ResolutionContext(docs_root=Path("/docs"), source_file=Path("/docs/a.md"))
+ assert pickle.loads(pickle.dumps(ctx)) == ctx
+```
+
+Questo test esiste già nella suite di test a partire da v0.5.0a4.
+
+**Integrità del Reporting Shield (Il Mutation Gate per il Commit 2+):**
+
+Il requisito di conformità per il mutation score dello Shield è **più ampio**
+della sola detection. Riguarda anche la **pipeline di reporting**:
+
+> *Un segreto che viene rilevato ma non segnalato correttamente è un bug CRITICO —
+> indistinguibile da un segreto che non è mai stato rilevato.*
+
+Qualsiasi PR che tocca la funzione `_map_shield_to_finding()`, il percorso di
+severità `SECURITY_BREACH` in `SentinelReporter`, o il routing dell'exit code in
+`cli.py` **deve uccidere tutti e tre questi mutanti obbligatori** prima che la PR
+venga accettata:
+
+| Nome mutante | Cosa cambierebbe mutmut | Test che deve ucciderlo |
+|-------------|------------------------|------------------------|
+| **L'Invisibile** | `severity="security_breach"` → `severity="warning"` | L'exit code deve essere 2, non 1 |
+| **L'Amnesico** | Rimuove l'offuscamento → espone il segreto completo | L'output del log non deve contenere la stringa grezza |
+| **Il Silenziatore** | `findings.append(...)` → `pass` | L'asserzione sul conteggio dei finding deve fallire |
+
+**Eseguire il mutation gate con scope sullo Shield:**
+
+```bash
+nox -s mutation -- src/zenzic/core/scanner.py
+```
+
+Risultato atteso prima del merge di qualsiasi PR Commit 2+:
+
+```text
+Killed: XXX, Survived: Y
+Mutation score: ≥ 90.0%
+```
+
+Se il punteggio è sotto il 90%, aggiungi test mirati prima di riaprire la PR. Non
+contrassegnare mutanti sopravvissuti come equivalenti senza l'esplicita approvazione
+del responsabile architettura.
---
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 48c6ae6..0a3d75b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,7 +41,7 @@ the exact same environment as CI.
| **Self-lint** | **`just check`** | — | **Run Zenzic on its own documentation (strict)** |
| Test suite | `just test` | `nox -s tests` | pytest + branch coverage (Hypothesis **dev** profile) |
| Test suite (thorough) | `just test-full` | — | pytest with Hypothesis **ci** profile (500 examples) |
-| Mutation testing | — | `nox -s mutation` | mutmut on `src/zenzic/core/rules.py` |
+| Mutation testing | — | `nox -s mutation` | mutmut on `rules.py`, `shield.py`, `reporter.py` |
| Full pipeline | `just preflight` | `nox -s preflight` | lint, typecheck, tests, reuse, security |
| **Pre-push gate** | **`just verify`** | — | **preflight + production build — run before every push** |
| Docs build (fast) | `just build` | — | mkdocs build, no strict enforcement |
@@ -161,6 +161,126 @@ is hosted at the domain root. If documentation is served from a subdirectory (e.
`https://example.com/assets/logo.png` (404), not to the intended asset. Use relative paths
(`../assets/logo.png`) to guarantee portability regardless of the hosting environment.
+### VSM Sovereignty
+
+Any existence check on an internal resource (page, image, anchor) **must** query
+the Virtual Site Map — never the filesystem.
+
+**Why:** The VSM includes **Ghost Routes** — canonical URLs generated by build
+plugins (e.g. `reconfigure_material: true`) that have no physical `.md` file
+on disk. A `Path.exists()` call returns `False` for a Ghost Route. The VSM
+returns `REACHABLE`. The VSM is the oracle; the filesystem is not.
+
+**Grade-1 violation:** Using `os.path.exists()`, `Path.is_file()`, or any other
+filesystem probe to validate an internal link is a Grade-1 architectural
+violation. PRs containing this pattern will be closed without review.
+
+```python
+# ❌ Grade-1 violation — asks the filesystem, misses Ghost Routes
+if (docs_root / relative_path).exists():
+ ...
+
+# ✅ Correct — asks the VSM
+route = vsm.get(canonical_url)
+if route and route.status == "REACHABLE":
+ ...
+```
+
+Related: see `docs/arch/vsm_engine.md` — *Anti-Pattern Catalogue* for the
+complete list of banned filesystem calls inside rules.
+
+### Ghost Route Awareness
+
+Orphan detection rules must respect routes flagged as Ghost Routes in the VSM.
+A Ghost Route is not an orphan — it is a route that the build engine generates
+at build time from a plugin, with no source `.md` file.
+
+**Action:** Every new global-scan rule that performs orphan detection must
+accept an `include_ghosts: bool = False` constructor parameter. When
+`include_ghosts=False` (the default), routes with `status == "ORPHAN_BUT_EXISTING"`
+that were generated by a Ghost Route mechanism must be excluded from findings.
+
+```python
+class MyOrphanRule(BaseRule):
+ def __init__(self, include_ghosts: bool = False) -> None:
+ self._include_ghosts = include_ghosts
+
+ def check_vsm(self, file_path, text, vsm, anchors_cache, context=None):
+ for url, route in vsm.items():
+ if route.status == "ORPHAN_BUT_EXISTING":
+ # Skip Ghost Route-derived orphans unless explicitly included
+ if not self._include_ghosts and _is_ghost_derived(route):
+ continue
+ ...
+```
+
+### Root Discovery Protocol (RDP)
+
+`find_repo_root()` is the single entry point through which Zenzic establishes
+its **Workspace boundary**. Everything else — VSM construction, link
+resolution, config loading — depends on the path it returns. Treat it as load-
+bearing infrastructure.
+
+#### The Authority of Root
+
+Zenzic does not analyse files in isolation. It analyses a **Workspace**: a
+bounded set of files whose relationships — links, anchors, nav entries, orphan
+status — are only meaningful relative to a shared root. The Root is the
+inviolable outer wall of the VSM. A check that escapes this wall is not a
+Zenzic check; it is a vulnerability.
+
+#### Standard Inheritance — Why `.git`?
+
+`.git` is used as a proxy for the user's declared intent. The presence of a
+`.git` directory means the user has already established a VCS boundary for this
+project. Zenzic inherits that boundary rather than inventing its own. This also
+keeps Zenzic forward-compatible with future `.gitignore`-aware exclusions:
+automate exclusion of `site/`, `dist/`, and other generated artefacts that
+already exist in most `.gitignore` files.
+
+`zenzic.toml` is the fallback marker for environments without VCS (e.g. a
+documentation-only project, a CI container with a shallow checkout). If
+`zenzic.toml` exists, Zenzic uses its directory as the root — no `.git` required.
+
+#### Opt-in Safety — The Default Must Be Safe
+
+The failure-by-default behaviour is intentional. An invocation of
+`zenzic check all` from `/home/user/` with no root marker anywhere in the
+ancestor chain raises `RuntimeError` immediately, before a single file is read.
+This is not a usability defect — it is a **safety guarantee**. The alternative
+(silently defaulting to CWD or the filesystem root) would expose Zenzic to
+accidental Massive Indexing: scanning thousands of unrelated files, producing
+meaningless findings, and potentially leaking information across project
+boundaries in CI environments.
+
+**Mutation of this default requires Architecture Lead approval.** A PR that
+changes `fallback_to_cwd=False` to `True` in any call site other than `init`
+is a Grade-1 safety violation and will be closed without review.
+
+#### The Bootstrap Exception
+
+Only `zenzic init` is exempt from the strict root requirement. Its purpose is
+to *create* the root marker — requiring the marker to pre-exist would be the
+Bootstrap Paradox (ZRT-005). The exemption is encoded as a keyword-only
+parameter so the call site is self-documenting and auditable by inspection:
+
+```python
+# ✅ Only permitted in cli.py::init — creates a new perimeter from scratch
+repo_root = find_repo_root(fallback_to_cwd=True)
+
+# ✅ All other commands — strict perimeter enforcement, raises outside a repo
+repo_root = find_repo_root()
+```
+
+Adding `fallback_to_cwd=True` to any command other than `init` requires a
+recorded Architecture Decision Record explaining why that command needs
+perimeter-free access.
+
+See [ADR 003](docs/adr/003-discovery-logic.md) for the full rationale and
+the ZRT-005 amendment history.
+
+---
+
## Security & Compliance
- **Security First:** Any new path resolution MUST be tested against Path Traversal. Use `PathTraversal` logic from `core`.
@@ -169,6 +289,271 @@ is hosted at the domain root. If documentation is served from a subdirectory (e.
---
+## The Shield & The Canary
+
+This section documents the **four security obligations** that apply to every
+PR touching `src/zenzic/core/`. A PR that resolves a bug without satisfying
+all four will be rejected by the Architecture Lead.
+
+These rules exist because the v0.5.0a3 security review (2026-04-04) demonstrated
+that four individually reasonable design choices — each correct in isolation —
+composed into four distinct attack vectors. See
+`docs/internal/security/shattered_mirror_report.md` for the full post-mortem.
+
+---
+
+### Obligation 1 — The Security Tax (Worker Timeout)
+
+Every PR that modifies `ProcessPoolExecutor` usage in `scanner.py` must
+preserve the `future.result(timeout=_WORKER_TIMEOUT_S)` call. The current
+timeout is **30 seconds**.
+
+**What this means:**
+
+```python
+# ✅ Required form — always use submit() + result(timeout=...)
+futures_map = {executor.submit(_worker, item): item[0] for item in work_items}
+for fut, md_file in futures_map.items():
+ try:
+ raw.append(fut.result(timeout=_WORKER_TIMEOUT_S))
+ except concurrent.futures.TimeoutError:
+ raw.append(_make_timeout_report(md_file)) # Z009 finding
+
+# ❌ Forbidden — blocks indefinitely on ReDoS or deadlocked workers
+raw = list(executor.map(_worker, work_items))
+```
+
+**The Z009 finding** (`ANALYSIS_TIMEOUT`) is not a crash. It is a structured
+finding that surfaces in the standard report UI. A worker that times out does
+not kill the scan — the coordinator continues with the remaining workers.
+
+**If your change naturally requires a longer timeout** (e.g. a new rule
+performs expensive computation), increase `_WORKER_TIMEOUT_S` with a comment
+explaining the cost and a benchmark proving the worst-case input.
+
+---
+
+### Obligation 2 — The Regex-Canary Protocol
+
+Every `[[custom_rules]]` entry that specifies a `pattern` is subject to the
+**Regex-Canary**, a POSIX `SIGALRM`-based stress test that runs at
+`AdaptiveRuleEngine` construction time.
+
+**How the canary works:**
+
+```python
+# _assert_regex_canary() in rules.py — runs automatically for every CustomRule
+_CANARY_STRINGS = (
+ "a" * 30 + "b", # classic (a+)+ trigger
+ "A" * 25 + "!", # uppercase variant
+ "1" * 20 + "x", # numeric variant
+)
+_CANARY_TIMEOUT_S = 0.1 # 100 ms
+```
+
+The canary applies each of the three strings to the rule's `check()` method
+under a 100 ms watchdog. If the pattern does not complete within 100 ms on
+any of these strings, the engine raises `PluginContractError` before the scan
+begins.
+
+**Testing your pattern against the canary before committing:**
+
+```python
+from pathlib import Path
+from zenzic.core.rules import CustomRule, _assert_regex_canary
+from zenzic.core.exceptions import PluginContractError
+
+rule = CustomRule(
+ id="MY-001",
+ pattern=r"your-pattern-here",
+ message="Found.",
+ severity="warning",
+)
+
+try:
+ _assert_regex_canary(rule)
+ print("✅ Canary passed — pattern is safe for production")
+except PluginContractError as e:
+ print(f"❌ Canary failed — ReDoS risk detected:\n{e}")
+```
+
+Or from the shell:
+
+```bash
+uv run python -c "
+from zenzic.core.rules import CustomRule, _assert_regex_canary
+r = CustomRule(id='T', pattern=r'YOUR_PATTERN', message='.', severity='warning')
+_assert_regex_canary(r)
+print('safe')
+"
+```
+
+**Patterns to avoid** (catastrophic backtracking triggers):
+
+| Pattern | Why dangerous |
+|---------|---------------|
+| `(a+)+` | Nested quantifiers — exponential paths |
+| `(a\|aa)+` | Alternation with overlap |
+| `(a*)*` | Nested star — infinite empty matches |
+| `.+foo.+bar` | Greedy multi-wildcard with suffix |
+
+**Patterns that are always safe:**
+
+| Pattern | Notes |
+|---------|-------|
+| `TODO` | Literal match, O(n) |
+| `^(DRAFT\|WIP):` | Anchored alternation, O(1) at each position |
+| `[A-Z]{3}-\d+` | Bounded character classes |
+| `\bfoo\b` | Word-boundary anchored |
+
+**Platform note:** `_assert_regex_canary()` uses `signal.SIGALRM`, which is
+only available on POSIX systems (Linux, macOS). On Windows, the canary is a
+no-op. The worker timeout (Obligation 1) is the universal backstop.
+
+**Canary overhead:** Measured at **0.12 ms** per engine construction with 10
+safe rules (20-iteration median). This is a one-time cost at scan startup and
+is well within the acceptable "Security Tax" budget.
+
+---
+
+### Obligation 3 — The Shield's Dual-Stream Invariant
+
+The Shield stream and the Content stream in `ReferenceScanner.harvest()` must
+**never share a generator**. This is the architectural lesson from ZRT-001.
+
+```python
+# ✅ CORRECT — independent generators, independent filtering contracts
+with file_path.open(encoding="utf-8") as fh:
+ for lineno, line in enumerate(fh, start=1): # Shield: ALL lines
+ list(scan_line_for_secrets(line, file_path, lineno))
+
+for lineno, line in _iter_content_lines(file_path): # Content: filtered
+ ...
+
+# ❌ FORBIDDEN — sharing a generator silently drops frontmatter from Shield
+with file_path.open(encoding="utf-8") as fh:
+ shared = _skip_frontmatter(fh)
+ for lineno, line in shared:
+ list(scan_line_for_secrets(...)) # ← blind to frontmatter
+ for lineno, line in shared: # ← already exhausted
+ ...
+```
+
+**Shield performance:** The dual-scan (raw + normalised line) runs at
+approximately **235,000 lines/second** (measured: 12.74 ms median for 3,000
+lines over 20 iterations). The normalizer adds one pass per line but the
+`seen` set prevents duplicate findings, keeping output deterministic.
+
+If a PR refactors `harvest()` and the CI benchmark drops below **100,000
+lines/second**, reject and investigate before merging.
+
+---
+
+### Obligation 4 — Mutation Score ≥ 90% for Core Changes
+
+Any PR that modifies `src/zenzic/core/` must maintain or improve the mutation
+score on the affected module. The current baseline for `rules.py` is **86.7%**
+(242/279 mutants killed).
+
+The target for rc1 is **≥ 90%**. A PR that adds a new rule or modifies
+detecting logic without killing the corresponding mutants will be rejected.
+
+**Running mutation testing:**
+
+```bash
+nox -s mutation
+```
+
+**Interpreting surviving mutants:**
+
+Not all surviving mutants are equivalent. Before marking a mutant as
+acceptable, confirm that:
+
+1. The mutant changes observable behaviour (it is not logically equivalent).
+2. No existing test catches the mutant (it is a genuine gap).
+3. Adding a test to kill it would be redundant or trivially circular.
+
+If unsure, add the test. The mutation suite is a living document of the
+Sentinel's threat model.
+
+**ResolutionContext pickle validation (Eager Validation 2.0):**
+
+`ResolutionContext` is a `@dataclass(slots=True)` with only `Path` fields.
+`Path` is pickleable by the standard library. The object serializes to 157
+bytes. However, if `ResolutionContext` ever gains a field that is not
+pickleable (e.g. a file handle, a lock, a lambda), the parallel engine will
+fail silently.
+
+To guard against this, any PR that adds a field to `ResolutionContext` must
+include:
+
+```python
+# In tests/test_redteam_remediation.py (or a dedicated test):
+def test_resolution_context_is_pickleable():
+ import pickle
+ ctx = ResolutionContext(docs_root=Path("/docs"), source_file=Path("/docs/a.md"))
+ assert pickle.loads(pickle.dumps(ctx)) == ctx
+```
+
+This test already exists in the test suite as of v0.5.0a4.
+
+**Shield Reporting Integrity (The Mutation Gate for Commit 2+):**
+
+The conformance requirement for the mutation score on the Shield is **broader**
+than detection alone. It also covers the **reporting pipeline**:
+
+> *A secret that is detected but not correctly reported is a CRITICAL bug —
+> indistinguishable from a secret that was never detected at all.*
+
+Any PR that touches the `_map_shield_to_finding()` conversion function,
+the `SECURITY_BREACH` severity path in `SentinelReporter`, or the exit-code
+routing in `cli.py` **must kill all three of these mandatory mutants** before
+the PR is accepted:
+
+| Mutant name | What is changed | Test that must kill it |
+|-------------|----------------|------------------------|
+| **The Invisible** | `severity="security_breach"` → `severity="warning"` in `_map_shield_to_finding()` | `test_map_always_emits_security_breach_severity` |
+| **The Amnesiac** | `_obfuscate_secret()` returns `raw` instead of the redacted form | `test_obfuscate_never_leaks_raw_secret` |
+| **The Silencer** | `_map_shield_to_finding()` returns `None` instead of a `Finding` | `test_pipeline_appends_breach_finding_to_list` |
+
+**Running the mutation gate:**
+
+```bash
+nox -s mutation
+```
+
+The session targets `rules.py`, `shield.py`, and `reporter.py` as configured in
+`[tool.mutmut]` in `pyproject.toml`. No posargs are required.
+
+> **Infrastructure note — `mutmut_pytest.ini`:**
+> `mutmut` v3 generates trampolines in a `mutants/` working copy. For these
+> to be visible to pytest, `mutants/src/` must precede the installed
+> site-packages on `sys.path`. `mutmut_pytest.ini` (tracked in the repo)
+> provides an isolated pytest config (`import-mode=prepend`,
+> `pythonpath = src`) used exclusively by the `nox -s mutation` session.
+> The main `pyproject.toml` pytest config is not affected.
+
+**Fallback — Manual Mutation Verification (The Sentinel's Trial):**
+
+If the automated tool cannot report a score (e.g. due to an editable-install
+mapping issue), apply each mutant by hand and confirm the test fails:
+
+```bash
+# 1. Apply mutant, run the specific test, confirm FAIL, revert.
+git diff # verify only one targeted line changed
+pytest tests/test_redteam_remediation.py::TestShieldReportingIntegrity -v
+git checkout -- src/ # revert
+```
+
+Manual verification is accepted as a temporary waiver per Architecture Lead
+approval. Document the results in the PR description before merging.
+
+If the score is below 90% (automated) or any of the three trials pass when
+they should fail (manual), add targeted tests before reopening the PR. Do not
+mark surviving mutants as equivalent without explicit Architecture Lead approval.
+
+---
+
## Adding a new check
Zenzic's checks live in `src/zenzic/core/`. Each check is a standalone function in either `scanner.py` (filesystem traversal) or `validator.py` (content validation). CLI wiring is in `cli.py`.
diff --git a/INTERNAL_GLOSSARY.toml b/INTERNAL_GLOSSARY.toml
new file mode 100644
index 0000000..1072fa8
--- /dev/null
+++ b/INTERNAL_GLOSSARY.toml
@@ -0,0 +1,100 @@
+# SPDX-FileCopyrightText: 2026 PythonWoods
+# SPDX-License-Identifier: Apache-2.0
+#
+# Zenzic Internal Technical Glossary
+# Canonical EN ↔ IT term registry for consistent documentation and code comments.
+# Maintained by S-0 "The Chronicler".
+#
+# Rule: consult this file before introducing a new technical term in docs/ or docs/it/.
+# Rule: every term introduced in one language must have a corresponding entry here.
+# Rule: terms marked stable=true must not be renamed without an ADR.
+
+[[terms]]
+en = "Safe Harbor"
+it = "Porto Sicuro"
+stable = true
+notes = "The project's core metaphor: a stable, bounded analysis perimeter free from false positives."
+
+[[terms]]
+en = "Ghost Route"
+it = "Rotta Fantasma"
+stable = true
+notes = "A REACHABLE canonical URL generated by a build plugin (e.g. MkDocs i18n) with no physical .md source file. Ghost Routes are terminal nodes — they cannot be members of a cycle."
+
+[[terms]]
+en = "Root Discovery Protocol (RDP)"
+it = "Protocollo di Scoperta della Radice (PSR)"
+stable = true
+notes = "The algorithm and invariants governing find_repo_root(). Documented in ADR 003. The only Genesis Fallback (fallback_to_cwd=True) is permitted exclusively in the init command."
+
+[[terms]]
+en = "Virtual Site Map (VSM)"
+it = "Mappa del Sito Virtuale (MSV)"
+stable = true
+notes = "Logical projection of the rendered site: VSM = dict[str, Route]. Maps every canonical URL to its routing status (REACHABLE, ORPHAN_BUT_EXISTING, IGNORED)."
+
+[[terms]]
+en = "Two-Pass Engine"
+it = "Motore a Due Passaggi"
+stable = true
+notes = "Phase 1 = parallel I/O (anchor + link indexing). Phase 2 = O(1) per-query validation against in-memory maps. No disk reads in Phase 2."
+
+[[terms]]
+en = "Shield"
+it = "Scudo"
+stable = true
+notes = "The secret-detection subsystem. Dual-stream invariant: Stream 1 reads ALL lines raw (ZRT-001); Stream 2 uses _iter_content_lines(). Streams must never be shared."
+
+[[terms]]
+en = "Bootstrap Paradox"
+it = "Paradosso Bootstrap"
+stable = true
+notes = "ZRT-005: the init command requires the root marker it is trying to create. Resolved by the Genesis Fallback (fallback_to_cwd=True)."
+
+[[terms]]
+en = "Adaptive Rule Engine"
+it = "Motore Adattivo delle Regole"
+stable = false
+notes = "Phase 3 of the two-pass pipeline: applies built-in and plugin-supplied rules to processed Markdown content."
+
+[[terms]]
+en = "Plugin Contract Error"
+it = "Errore di Contratto Plugin"
+stable = false
+notes = "Raised at boot when a plugin rule fails eager pickle-serializability validation or the regex canary test."
+
+[[terms]]
+en = "Regex Canary"
+it = "Canarino Regex"
+stable = false
+notes = "100ms SIGALRM watchdog that stress-tests CustomRule patterns for catastrophic backtracking. Only applies to CustomRule (not built-in patterns)."
+
+[[terms]]
+en = "Circular Link"
+it = "Link Circolare"
+stable = false
+notes = "A link whose resolved target is a member of a link cycle (detected by _find_cycles_iterative). Severity: info. Mutual navigation links between pages are valid; this is advisory."
+
+[[terms]]
+en = "Blood Sentinel"
+it = "Sentinella di Sangue"
+stable = false
+notes = "The component classifying PATH_TRAVERSAL findings by intent. Hrefs targeting OS system directories (/etc/, /root/, etc.) → PATH_TRAVERSAL_SUSPICIOUS → severity=security_incident → Exit Code 3."
+
+[[terms]]
+en = "Hex Shield"
+it = "Scudo Esadecimale"
+stable = false
+notes = "Built-in Shield pattern detecting hex-encoded payloads: 3+ consecutive \\xNN sequences. Threshold prevents false positives on single-escape regex examples."
+
+[[terms]]
+en = "Bilingual Parity Protocol"
+it = "Protocollo di Parità Bilingue"
+stable = true
+notes = "Architectural rule: every significant documentation change must have a corresponding Italian translation. Enforced by S-0 at commit review."
+
+[[terms]]
+en = "Tower of Babel Guard"
+it = "Guardia della Torre di Babele"
+stable = true
+notes = "The i18n fallback resolution logic: when a locale-specific file lacks a heading anchor, the validator checks the default-locale equivalent before flagging ANCHOR_MISSING."
diff --git a/README.it.md b/README.it.md
index 6f1a204..729da50 100644
--- a/README.it.md
+++ b/README.it.md
@@ -29,8 +29,8 @@ SPDX-License-Identifier: Apache-2.0
-
-
+
+
diff --git a/README.md b/README.md
index 5203f8f..2f36bac 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,8 @@ SPDX-License-Identifier: Apache-2.0
-
-
+
+
diff --git a/RELEASE.md b/RELEASE.md
index 2a1f77f..14fc4a8 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,606 +1,212 @@
-# Zenzic v0.5.0a3: The Sentinel — Aesthetic Identity, Parallel Anchors & Agnostic Target
+# Zenzic v0.5.0a4 — Pre-Release Audit Package
-## v0.5.0a3 — The Sentinel: Aesthetic Sprint + Performance & SDK
+**Prepared by:** S-1 (Auditor) + S-0 (Chronicler)
+**Date:** 2026-04-08
+**Status:** ALPHA — Pending Tech Lead manual verification before rc1 promotion
+**Branch:** `fix/sentinel-hardening-v0.5.0a4`
-**Release date:** 2026-04-03
-**Status:** Alpha 3 — two-phase anchor indexing, plugin SDK scaffolding, Sentinel Palette,
-agnostic target mode, native Material header
-
-### Highlights
+> **Tech Lead note:** This document is your single audit surface. Work through each
+> section in order. When every checkbox below is ticked, the project is ready for
+> the `rc1` tag. Until then, the "Alpha" designation stands.
---
-#### 🎨 Sentinel Palette — Color Identity for the Report Engine
-
-The report engine now speaks a deliberate visual language. Every number, every gutter
-marker, every severity badge has an assigned color drawn from a named palette:
-
-| Role | Color | Example |
-| :--- | :---- | :------ |
-| Numeric values (counts, scores, elapsed) | Indigo | `12 files`, `0.1s` |
-| Gutter (`│` separator, line numbers) | Slate | `3 │ # Heading` |
-| Error icon, label, count | Rose | `✘ 2 errors` |
-| Warning icon, label, count | Amber | `⚠ 5 warnings` |
+## 1. Version Anchors
-Bold has been removed from all report numbers — color alone carries the weight. The
-palette is defined in `src/zenzic/ui.py`, a new standalone module consumed by both
-the reporter and the CLI banner.
+| Location | Expected | Actual | Status |
+| :--- | :--- | :--- | :---: |
+| `src/zenzic/__init__.py` | `0.5.0a4` | `0.5.0a4` | ✅ |
+| `CHANGELOG.md` top entry | `[0.5.0a4]` | `[0.5.0a4]` | ✅ |
+| `CHANGELOG.it.md` top entry | `[0.5.0a4]` | `[0.5.0a4]` | ✅ |
+| No `rc1` in top-level version files | — | verified | ✅ |
---
-#### 📡 Unified Banner Telemetry
-
-The Sentinel banner now emits a single unified counter:
+## 2. Quality Gates
```text
-vanilla • ./README.md • 1 file (1 docs, 0 assets) • 0.0s
-mkdocs • 104 files (66 docs, 38 assets) • 3.5s
-mkdocs • ./content/ • 2 files (2 docs, 0 assets) • 0.1s
+pytest 756 passed, 0 failed
+zenzic check all ✔ All checks passed (18 info-level CIRCULAR_LINK — expected)
+ --strict
```
-`docs` = `.md` files + config files (`yml`/`yaml`/`toml`) inside `docs_root`,
-plus engine config files (`mkdocs.yml` etc.) at project root.
-`assets` = everything else non-inert (images, fonts, PDFs…).
-
----
-
-#### 🎯 Agnostic Target Support — Scope Any Audit
-
-`zenzic check all` now accepts a positional `PATH` argument:
-
-```bash
-# Audit a single file outside your docs tree
-zenzic check all README.md
+Gate targets for rc1 promotion:
-# Audit an entire custom content directory
-zenzic check all content/
-
-# Audit a single page inside docs
-zenzic check all docs/guide/setup.md
-```
-
-Zenzic auto-selects `VanillaAdapter` for out-of-tree targets. `docs_dir` is
-patched at runtime — `zenzic.toml` is never rewritten. The banner shows the
-active target so there is no ambiguity about what was scanned.
-
-Two new example projects ship with this release:
-
-- `examples/single-file-target/` — demonstrates `zenzic check all README.md`
-- `examples/custom-dir-target/` — demonstrates `zenzic check all content/`
+- [ ] `pytest` ≥ 756 passed, 0 failed
+- [ ] `zenzic check all --strict` → exit code 0, no errors, no warnings
+- [ ] `ruff check src/` → 0 violations
+- [ ] `mypy src/` → 0 errors
+- [ ] `mkdocs build --strict` → 0 warnings
---
-#### ⚡ Two-Phase Parallel Anchor Indexing
-
-`validate_links_async` now separates concerns into two deterministic phases:
-
-1. **Phase 1 — Parallel index:** each worker extracts per-file anchors and
- resolves internal links independently. No shared state; no race conditions.
-
-2. **Phase 2 — Global validation:** the main process merges all anchor indexes
- and validates every link in a single pass. Order no longer matters.
-
-The result: no false positive `AnchorMissing` findings under heavy parallelism.
-A 1000-file anchor torture test ships as a regression guard.
+## 3. New Features in v0.5.0a4 — Review Checklist
----
+### 3.1 Blood Sentinel (Exit Code 3)
-#### 🔌 Plugin SDK — First-Class Developer Surface
+**What it does:** path-traversal hrefs pointing to OS system directories
+(`/etc/`, `/root/`, `/var/`, `/proc/`, `/sys/`, `/usr/`) are classified as
+`PATH_TRAVERSAL_SUSPICIOUS` → severity `security_incident` → **Exit Code 3**.
+Exit 3 takes priority over Exit 2 (credential breach). Never suppressed by
+`--exit-zero`.
-```bash
-zenzic init --plugin my-org-rules
-```
+**Files changed:**
-Generates a complete Python package skeleton:
+- `src/zenzic/ui.py` — `BLOOD = "#8b0000"` palette constant
+- `src/zenzic/core/reporter.py` — `security_incident` severity style (blood red)
+- `src/zenzic/core/validator.py` — `_RE_SYSTEM_PATH`, `_classify_traversal_intent()`
+- `src/zenzic/cli.py` — Exit Code 3 check in `check links` and `check all`
-- `pyproject.toml` with `zenzic.rules` entry-point wiring
-- `src/my_org_rules/rules.py` with a `BaseRule` template
-- Minimal docs fixture so `zenzic check all` runs immediately on the scaffold
+**Tests:** `TestTraversalIntent` (4 tests) + 2 exit-code integration tests in `test_cli.py`
-The `zenzic.rules` public namespace is now stable — `BaseRule`, `RuleFinding`,
-`CustomRule`, `Violation`, `Severity` are importable from a single path that will
-not change between minor versions.
+**Verification steps for Tech Lead:**
-`run_rule()` — a one-call test helper — lets plugin authors verify findings without
-any engine setup.
-
-`examples/plugin-scaffold-demo/` ships as the canonical scaffold output fixture,
-serving as both a DX reference and a quality-gate integration test.
+- [ ] Review `_classify_traversal_intent()` in `src/zenzic/core/validator.py`
+- [ ] Verify `PATH_TRAVERSAL_SUSPICIOUS` → `security_incident` mapping in `cli.py`
+- [ ] Verify Exit 3 is checked **before** Exit 2 in `check all` exit logic
+- [ ] Confirm `--exit-zero` does NOT suppress Exit 3
+- [ ] Read `docs/checks.md` § "Blood Sentinel — system-path traversal"
---
-#### ⚡ Smart Initialization — `zenzic init --pyproject`
-
-`zenzic init` now detects `pyproject.toml` in the project root and interactively
-asks whether to embed configuration as a `[tool.zenzic]` table instead of creating
-a standalone `zenzic.toml`.
+### 3.2 Graph Integrity — Circular Link Detection
-```bash
-zenzic init # interactive: asks if pyproject.toml exists
-zenzic init --pyproject # skip the prompt, write directly into pyproject.toml
-zenzic init --force # overwrite existing config (both modes)
-```
+**What it does:** Phase 1.5 pre-computes a cycle registry via iterative DFS
+(Θ(V+E)). Phase 2 checks each resolved link against the registry in O(1). Links
+in a cycle emit `CIRCULAR_LINK` at severity **`info`** (not error or warning).
-Engine auto-detection (`mkdocs.yml` → `engine = "mkdocs"`, `zensical.toml` →
-`engine = "zensical"`) works in both standalone and pyproject modes. When no
-engine config file is found, vanilla defaults apply.
+**Design decision — why `info`:**
+The project's own documentation has ~34 intentional mutual navigation links
+(Home ↔ Features, CI/CD ↔ Usage, etc.). Making this `warning` or `error` would
+permanently break `--strict` self-check. The `info` level surfaces the topology
+without blocking valid builds.
----
+**Files changed:**
-#### 🛡️ Z001 / Z002 Split — Errors vs Warnings for Link Issues (closes #6)
+- `src/zenzic/core/validator.py` — `_build_link_graph()`, `_find_cycles_iterative()`, Phase 1.5 block
-`VSMBrokenLinkRule` now distinguishes:
+**Tests:** `TestFindCyclesIterative` (6 unit tests) + `TestCircularLinkIntegration` (3 integration tests)
-| Code | Meaning | Severity |
-| :--- | :------ | :------- |
-| `Z001` | Link target not found in file system or VSM | **error** |
-| `Z002` | Link target exists but is an orphan page (not in nav) | warning |
+**Verification steps for Tech Lead:**
-Without `--strict`, orphan-link warnings do not block the build. With `--strict`
-they are promoted to errors. Both codes appear in the checks reference (EN + IT).
+- [ ] Review `_find_cycles_iterative()` — WHITE/GREY/BLACK DFS correctness
+- [ ] Confirm `CIRCULAR_LINK` severity = `"info"` in `cli.py` Finding constructor
+- [ ] Confirm CIRCULAR_LINK never triggers Exit 1 or Exit 2
+- [ ] Read `docs/checks.md` § "Circular links"
+- [ ] Run `zenzic check all --strict` and confirm only info findings, exit 0
---
-#### 🌐 Native Material Header — MutationObserver injection
+### 3.3 Hex Shield
-The `source.html` template override has been deleted. Version injection now uses a
-`MutationObserver` snippet in `main.html` that writes directly into Material's own
-top bar after the widget renders.
+**What it does:** built-in Shield pattern `hex-encoded-payload` detects
+3+ consecutive `\xNN` hex escape sequences. Threshold prevents FP on
+single-escape regex examples.
-Result: a single, clean header row — 🏷 0.5.0a3 · ☆ stars · ψ forks — with no
-duplicate rendering, no JavaScript collision, and no Material upgrade risk.
+**Files changed:**
----
+- `src/zenzic/core/shield.py` — one line appended to `_SECRETS`
-#### 🔧 Pre-commit Hooks — Ship Ready
+**Tests:** 4 tests in `TestShield` in `test_references.py`
-`.pre-commit-hooks.yaml` is now included in the repository root. Teams can pin
-Zenzic as a pre-commit hook directly from GitHub without any intermediate wrapper:
+**Verification steps for Tech Lead:**
-```yaml
-repos:
- - repo: https://github.com/PythonWoods/zenzic
- rev: v0.5.0a3
- hooks:
- - id: zenzic-check-all
-```
+- [ ] Confirm pattern `(?:\\x[0-9a-fA-F]{2}){3,}` in `shield.py`
+- [ ] Confirm single `\xNN` is NOT flagged (threshold = 3)
+- [ ] Read `docs/usage/advanced.md` § "Detected credential patterns" table
---
-### Issue Closures
+### 3.4 INTERNAL_GLOSSARY.toml
-| Issue | Title | Status |
-| :---- | :---- | :----- |
-| #4 | Custom Rules DSL — Italian documentation | ✅ Closed |
-| #6 | Z001/Z002 split: orphan links should be warnings | ✅ Closed |
-| #13 | `zenzic.rules` stable public namespace for plugins | ✅ Closed |
+**What it does:** canonical EN↔IT term registry. 15 entries. `stable = true`
+entries require an ADR before renaming.
----
+**Verification steps for Tech Lead:**
-### Quality Gates
-
-```text
-pytest 706 passed, 0 failed
-coverage 80%+ branch (gate: ≥ 80%)
-mutation score 86.7% (242/279 killed on rules.py — target: 75%)
-ruff check src/ 0 violations
-mypy src/ 0 errors
-reuse lint 262/262 files compliant
-zenzic check all SUCCESS (self-dogfood, 104 files)
-mkdocs build --strict, 0 warnings
-```
+- [ ] Review all 15 terms — correct EN↔IT mapping?
+- [ ] All core concepts covered? (VSM, RDP, Shield, Blood Sentinel, etc.)
---
-### Mutation Testing Campaign — "The Mutant War"
-
-v0.5.0a3 ships with a full mutation testing campaign against `src/zenzic/core/rules.py`
-using **mutmut 3.5.0**. The campaign raised the mutation score from 58.1% (baseline)
-to **86.7%** (242/279 killed) — exceeding the 75% target by +11.7 percentage points.
+## 4. Documentation Parity Matrix
-**80 new targeted tests** were added to `test_rules.py`, organised in 7 specialised
-test classes covering:
+| Document | EN | IT | Hex Shield | Blood Sentinel | Circular Links |
+| :--- | :---: | :---: | :---: | :---: | :---: |
+| `docs/checks.md` | ✅ | ✅ | — | ✅ | ✅ |
+| `docs/it/checks.md` | — | ✅ | — | ✅ | ✅ |
+| `docs/usage/advanced.md` | ✅ | ✅ | ✅ | — | — |
+| `docs/it/usage/advanced.md` | — | ✅ | ✅ | — | — |
+| `CHANGELOG.md` | ✅ | — | ✅ | ✅ | ✅ |
+| `CHANGELOG.it.md` | — | ✅ | ✅ | ✅ | ✅ |
-- **PluginRegistry** (27 tests) — discovery, duplicates, case-sensitivity, `validate_rule()`
-- **VSMBrokenLinkRule** (22 tests) — `check_vsm` path/anchor resolution, orphan detection
-- **Inline link extraction** (14 tests) — escaped brackets, empty hrefs, multi-link lines
-- **AdaptiveRuleEngine** (10 tests) — `run()` and `run_vsm()` short-circuits and propagation
-- **Deep link extraction** (5 tests) — fence-block skipping, reference links, empty documents
-- **Pickleable assertions** (2 tests) — deep-copy guard and `UNREACHABLE` sentinel
+**Check for Tech Lead:**
-The 37 surviving mutants were analysed and classified as equivalent mutations
-(no observable behaviour change) or framework-level limitations (unreachable
-defensive assertions). **Practical quality saturation** has been reached.
-
-Hypothesis property-based testing is integrated with three severity profiles:
-`dev` (50 examples), `ci` (500), `purity` (1 000).
+- [ ] Read `docs/checks.md` §§ "Blood Sentinel" and "Circular links" — prose correct?
+- [ ] Read `docs/it/checks.md` §§ "Sentinella di Sangue" and "Link circolari" — translation accurate?
+- [ ] Read `docs/usage/advanced.md` Shield table — `hex-encoded-payload` row present and correct?
+- [ ] Read `docs/it/usage/advanced.md` — Italian row accurate?
---
-## Why this release matters now
+## 5. Exit Code Contract (complete picture)
-The documentation tooling ecosystem is fractured. MkDocs 2.0 is on the horizon, carrying breaking
-changes to plugin APIs and configuration formats. Zensical is emerging as a production-ready
-alternative. Teams are migrating, experimenting, and hedging. In this environment, any quality
-gate that is tightly coupled to a specific build engine has an expiry date.
+| Exit Code | Trigger | Suppressible |
+| :---: | :--- | :---: |
+| 0 | All checks passed | — |
+| 1 | One or more errors (broken links, syntax errors, etc.) | Via `--exit-zero` |
+| 2 | Shield credential detection | **Never** |
+| 3 | Blood Sentinel — system-path traversal (`PATH_TRAVERSAL_SUSPICIOUS`) | **Never** |
-v0.4.0 answers that uncertainty with a clear architectural commitment: **Zenzic will never break
-because your documentation engine changed.**
+Priority order in `check all`: Exit 3 → Exit 2 → Exit 1 → Exit 0.
-This is not a marketing claim. It is a precise technical guarantee backed by three design pillars
-and two sprints of structural surgery.
+- [ ] Tech Lead: verify this contract matches implementation in `cli.py`
---
-## The Three Pillars
-
-### 1. Source-first — no build required
-
-Zenzic analyses raw Markdown files and configuration as plain data. It never calls `mkdocs build`,
-never imports a documentation framework, never depends on generated HTML. A broken link is caught
-in 11 milliseconds against 5,000 files — before your CI runner has finished checking out the repo.
-
-This makes Zenzic usable as a pre-commit hook, a pre-build gate, a PR check, and a migration
-validator simultaneously. The same tool. The same score. The same findings. Regardless of which
-engine you run.
-
-### 2. No subprocesses in the Core
-
-The reference implementation of "engine-agnostic linting" is to shell out to the engine and parse
-its output. That approach inherits every instability of the engine: version skew, environment
-differences, missing binaries on CI runners.
-
-Zenzic's Core is pure Python. Link validation uses `httpx`. Nav parsing uses `yaml` and `tomllib`.
-There are no `subprocess.run` calls in the linting path. The engine binary does not need to be
-installed for `zenzic check all` to pass.
-
-### 3. Pure functions, pure results
-
-All validation logic in Zenzic lives in pure functions: no file I/O, no network access, no global
-state, no terminal output. I/O happens only at the edges — CLI wrappers that read files and print
-findings. Pure functions are trivially testable (706 passing tests, ≥ 80% branch-coverage gate), composable
-into higher-order pipelines, and deterministic across environments.
-
-The score you get on a developer laptop is the score CI gets. The score CI gets is the score you
-track in version control. Determinism is not a feature; it is the foundation on which `zenzic diff`
-and regression detection are built.
-
----
-
-## What's New in rc4
-
-### Ghost Routes — MkDocs Material i18n entry points
-
-When `reconfigure_material: true` is active in the i18n plugin, MkDocs Material
-auto-generates locale entry points (e.g. `it/index.md`) that never appear in `nav:`.
-The VSM now marks these as `REACHABLE` Ghost Routes, eliminating false orphan warnings
-on locale root pages. A `WARNING` is emitted when both `reconfigure_material: true`
-and `extra.alternate` are declared simultaneously (redundant configuration).
-
-### VSM Rule Engine — routing-aware lint rules
-
-`BaseRule` gains an optional `check_vsm()` interface. Rules that override it receive
-the full pre-built VSM and can validate links against routing state without any I/O.
-`RuleEngine.run_vsm()` dispatches all VSM-aware rules and converts `Violation` objects
-to the standard `RuleFinding` type for uniform output.
-
-The first built-in VSM rule — `VSMBrokenLinkRule` (code `Z001`) — validates all inline
-Markdown links against the VSM. A link is valid only when its target URL is present
-and `REACHABLE`. Both "not in VSM" and "UNREACHABLE_LINK" cases produce a structured
-`Violation` with file path, line number, and the offending source line as context.
-
-### Content-addressable cache (`CacheManager`)
-
-Rule results are now cached with SHA-256 keying:
-
-| Rule type | Cache key |
-| :--- | :--- |
-| Atomic (content only) | `SHA256(content) + SHA256(config)` |
-| Global (VSM-aware) | `SHA256(content) + SHA256(config) + SHA256(vsm_snapshot)` |
-
-Timestamps are never consulted — the cache is CI-safe by construction. Writes are
-atomic (`.tmp` rename). The cache is loaded once at startup and saved once at the end
-of a run; all in-run operations are pure in-memory.
-
-### Performance — O(N) torture tests (10k nodes)
-
-The VSM Rule Engine and cache infrastructure are validated at scale: 10,000 links all
-valid completes in < 1 s; 10,000 links all broken completes in < 1 s;
-`engine.run_vsm` with a 10,000-node VSM completes in < 0.5 s.
-
----
-
-## What Changed in rc3
-
-### i18n Anchor Fix — AnchorMissing now has i18n fallback suppression
-
-`AnchorMissing` now participates in the same i18n fallback logic as `FileNotFound`. Previously,
-a link like `[text](it/page.md#heading)` would fire a false positive when the Italian page existed
-but its heading was translated — because the `AnchorMissing` branch in `validate_links_async` had
-no suppression path. `_should_suppress_via_i18n_fallback()` was defined but never called.
-
-**Fix:** new `resolve_anchor()` method added to `BaseAdapter` protocol and all three adapters
-(`MkDocsAdapter`, `ZensicalAdapter`, `VanillaAdapter`). When an anchor is not found in a locale
-file, `resolve_anchor()` checks whether the anchor exists in the default-locale equivalent via
-the `anchors_cache` already in memory. No additional disk I/O.
-
-### Shared utility — `remap_to_default_locale()`
-
-The locale path-remapping logic that was independently duplicated in `resolve_asset()` and
-`is_shadow_of_nav_page()` is now a single pure function in `src/zenzic/core/adapters/_utils.py`.
-`resolve_asset()`, `resolve_anchor()`, and `is_shadow_of_nav_page()` in both `MkDocsAdapter` and
-`ZensicalAdapter` all delegate to it. `_should_suppress_via_i18n_fallback()`, `I18nFallbackConfig`,
-`_I18N_FALLBACK_DISABLED`, and `_extract_i18n_fallback_config()` — 118 lines of dead code —
-are permanently removed from `validator.py`.
-
-### Visual Snippets for custom rule findings
-
-Custom rule violations (`[[custom_rules]]` from `zenzic.toml`) now display the offending source
-line below the finding header:
-
-```text
-[ZZ-NODRAFT] docs/guide/install.md:14 — Remove DRAFT marker before publishing.
- │ > DRAFT: section under construction
-```
-
-The `│` indicator is rendered in the finding's severity colour. Standard findings (broken links,
-orphans, etc.) are unaffected.
-
-### JSON schema — 7 keys
-
-`--format json` output now emits a stable 7-key schema:
-`links`, `orphans`, `snippets`, `placeholders`, `unused_assets`, `references`, `nav_contract`.
-
-### `strict` and `exit_zero` as `zenzic.toml` fields
+## 6. Sandbox Self-Check
-Both flags can now be declared in `zenzic.toml` as project-level defaults:
-
-```toml
-strict = true # equivalent to always passing --strict
-exit_zero = false # exit code 0 even on findings (CI soft-gate)
-```
-
-CLI flags continue to override the TOML values.
-
-### Usage docs split — three focused pages
-
-`docs/usage/index.md` was a monolithic 580-line page covering install, commands, CI/CD, scoring,
-advanced features, and programmatic API. Split into three focused pages:
-
-- `usage/index.md` — Install options, init→config→check workflow, engine modes
-- `usage/commands.md` — CLI commands, flags, exit codes, JSON output, quality score
-- `usage/advanced.md` — Three-pass pipeline, Zenzic Shield, alt-text, programmatic API,
- multi-language docs
-
-Italian mirrors (`it/usage/`) updated in full parity.
-
-### Multi-language snippet validation
-
-`zenzic check snippets` now validates four languages using pure Python parsers — no subprocesses
-for any language. Python uses `compile()`, YAML uses `yaml.safe_load()`, JSON uses `json.loads()`,
-and TOML uses `tomllib.loads()` (Python 3.11+ stdlib). Blocks with unsupported language tags
-(`bash`, `javascript`, `mermaid`, etc.) are treated as plain text and not syntax-checked.
-
-### Shield deep-scan — no more blind spots
-
-The credential scanner now operates on every line of the source file, including lines inside
-fenced code blocks. A credential committed in a `bash` example is still a committed credential —
-Zenzic will find it. The link and reference validators continue to ignore fenced block content to
-prevent false positives from illustrative example URLs.
-
-The Shield now covers seven credential families: OpenAI API keys, GitHub tokens, AWS access keys,
-Stripe live keys, Slack tokens, Google API keys, and generic PEM private keys.
-
----
-
-## Professional Packaging & PEP 735
-
-v0.4.0-rc3 adopts the latest Python packaging standards end-to-end, making Zenzic lighter for
-end users and measurably faster in CI.
-
-### Lean core install
-
-`pip install zenzic` installs only the five runtime dependencies (`typer`, `rich`,
-`pyyaml`, `pydantic`, `httpx`). The MkDocs build stack is not a dependency of `zenzic` —
-it is a contributor tool, managed via the `docs` [PEP 735](https://peps.python.org/pep-0735/)
-dependency group (`uv sync --group docs`).
-
-For the vast majority of users (Hugo sites, Zensical projects, plain Markdown wikis, CI
-pipelines) this means a ~60% smaller install and proportionally faster cold-start times on
-ephemeral CI runners.
-
-### PEP 735 — atomic dependency groups
-
-Development dependencies are declared as [PEP 735](https://peps.python.org/pep-0735/) groups
-in `pyproject.toml`, managed by `uv`:
-
-| Group | Purpose | CI job |
-| :---- | :------ | :----- |
-| `test` | pytest + coverage | `quality` matrix (3.11 / 3.12 / 3.13) |
-| `lint` | ruff + mypy + pre-commit + reuse | `quality` matrix |
-| `docs` | MkDocs stack | `docs` job |
-| `release` | nox + bump-my-version + pip-audit | `security` job |
-| `dev` | All of the above (local development) | — |
-
-Each CI job syncs only the group it needs. The `quality` job never installs the MkDocs stack.
-The `docs` job never installs pytest. This eliminates install time wasted on unused packages
-and reduces the surface area for dependency conflicts across jobs. Combined with the `uv`
-cache in GitHub Actions, subsequent CI runs restore the full environment in under 3 seconds.
-
-### `CITATION.cff`
-
-A [`CITATION.cff`](CITATION.cff) file (CFF 1.2.0 format) is now present at the repository
-root. GitHub renders it automatically as a "Cite this repository" button. Zenodo, Zotero, and
-other reference managers that support the format can import it directly.
-
----
-
-## The Documentation Firewall
-
-v0.4.0-rc3 completes a strategic shift in what Zenzic is. It began as a link checker. It became
-an engine-agnostic linter. With rc3, it becomes a **Documentation Firewall** — a single gate that
-enforces correctness, completeness, and security simultaneously.
-
-The three dimensions of the firewall:
-
-**1. Correctness** — Zenzic validates the syntax of every structured data block in your docs.
-Your Kubernetes YAML examples, your OpenAPI JSON fragments, your TOML configuration snippets — if
-you ship broken config examples, your users will copy broken config. `check snippets` catches this
-before it reaches production, using the same parsers your users will run.
-
-**2. Completeness** — Orphan detection, placeholder scanning, and the `fail_under` quality gate
-ensure that every page linked in the nav exists, contains real content, and scores above the
-team's agreed threshold. A documentation site is not "done" when all pages exist — it is done
-when all pages are complete.
-
-**3. Security** — The Shield scans every line of every file, including code blocks, for seven
-families of leaked credentials. No fencing, no labels, no annotations can hide a secret from
-Zenzic. The exit code 2 contract is non-negotiable and non-suppressible: a secret in docs is a
-build-blocking incident, not a warning.
-
-This is what "Documentation Firewall" means: not a tool you run once before a release, but a
-gate that runs on every commit, enforces three dimensions of quality simultaneously, and exits
-with a machine-readable code that your CI pipeline can act on without human interpretation.
-
----
-
-## The Great Decoupling (v0.4.0-rc2)
-
-The headline change in this release is the **Dynamic Adapter Discovery** system. In v0.3.x,
-Zenzic owned its adapters — `MkDocsAdapter` and `ZensicalAdapter` were imported directly by the
-factory. Adding support for a new engine required a Zenzic release.
-
-In v0.4.0, Zenzic is a **framework host**. Adapters are Python packages that register themselves
-under the `zenzic.adapters` entry-point group. When installed, they become available immediately:
+Run these commands manually and verify output:
```bash
-# Example: third-party adapter for a hypothetical Hugo support package
-uv pip install zenzic-hugo-adapter # or: pip install zenzic-hugo-adapter
-zenzic check all --engine hugo
-```
-
-No Zenzic update. No configuration change. Just install and use.
-
-The built-in adapters (`mkdocs`, `zensical`, `vanilla`) are registered the same way — there is
-no privileged path for first-party adapters. This is not future-proofing; it is a structural
-guarantee that the third-party adapter API is exactly as capable as the first-party one.
+# 1. Full test suite
+uv run pytest --tb=short
-The factory itself is now protocol-only. `scanner.py` imports zero concrete adapter classes. The
-`has_engine_config()` protocol method replaced the `isinstance(adapter, VanillaAdapter)` check
-that was the last coupling point. The Core is now genuinely adapter-agnostic.
+# 2. Self-dogfood (strict mode)
+uv run zenzic check all --strict
----
-
-## The [[custom_rules]] DSL
-
-v0.4.0 ships the first version of the project-specific lint DSL. Teams can declare regex rules
-in `zenzic.toml` without writing any Python:
-
-```toml
-[[custom_rules]]
-id = "ZZ-NODRAFT"
-pattern = "(?i)\\bDRAFT\\b"
-message = "Remove DRAFT marker before publishing."
-severity = "warning"
+# 3. Static analysis
+uv run ruff check src/
+uv run mypy src/ --ignore-missing-imports
```
-Rules are adapter-independent — they fire identically with MkDocs, Zensical, or a plain
-Markdown folder. Patterns are compiled once at config-load time; there is no per-file regex
-compilation overhead regardless of how many rules are declared.
+Expected:
-This DSL is the first step toward Zenzic as a complete documentation policy engine, not just a
-structural linter.
+- pytest: 756 passed, 0 failed
+- check all --strict: exit 0, "✔ All checks passed"
+- ruff: 0 violations
+- mypy: 0 errors (or pre-existing stubs only)
---
-## The Shield (Defence-in-Depth hardening)
-
-The credential scanner (`Shield`) now runs on every non-definition line during Pass 1, not only
-on reference URL values. A developer who pastes an API key into a Markdown paragraph — not a
-reference link — is caught before any URL is pinged, before any HTTP request is issued, before
-any downstream tool sees the credential.
+## 7. rc1 Gate Decision
-Exit code `2` remains reserved exclusively for Shield events. It cannot be suppressed by
-`--exit-zero`, `--strict`, or any other flag. A Shield detection is a build-blocking security
-incident — unconditionally.
+This section is for the Tech Lead's signature.
----
-
-## Documentation as a first-class citizen
-
-The v0.4.0 documentation was itself validated with `zenzic check all` at every step — the
-canonical dogfood mandate.
-
-Key structural changes:
-
-- **Configuration split** — the single `configuration.md` god-page decomposed into four focused
- pages: [Overview](docs/configuration/index.md), [Core Settings](docs/configuration/core-settings.md),
- [Adapters & Engine](docs/configuration/adapters-config.md),
- [Custom Rules DSL](docs/configuration/custom-rules-dsl.md).
-- **Italian parity** — `docs/it/` now mirrors the full English structure. The documentation
- is production-ready for international teams.
-- **Migration guide** — [MkDocs → Zensical](docs/guide/migration.md) four-phase workflow with
- the baseline/diff/gate approach as the migration safety net.
-- **Adapter guide** — [Writing an Adapter](docs/developers/writing-an-adapter.md) full
- protocol reference, `from_repo` pattern, entry-point registration, and test utilities.
-
-### Frictionless Onboarding
-
-v0.4.0 introduces `zenzic init` — a single command that scaffolds a `zenzic.toml` with smart
-engine discovery. If `mkdocs.yml` is present, the generated file pre-sets `engine = "mkdocs"`.
-If `zensical.toml` is present, it pre-sets `engine = "zensical"`. Otherwise the scaffold is
-engine-agnostic (Vanilla mode).
-
-```bash
-uvx zenzic init # zero-install bootstrap
-# or: zenzic init # if already installed globally
-```
-
-For teams running Zenzic for the first time, a Helpful Hint panel appears automatically when no
-`zenzic.toml` is found — pointing directly to `zenzic init`. The hint disappears the moment the
-file is created. Zero friction to get started; zero noise once configured.
-
----
-
-## Upgrade path
-
-### From v0.3.x
-
-No `zenzic.toml` changes are required for MkDocs projects. The adapter discovery is fully
-backwards-compatible: `engine = "mkdocs"` continues to work exactly as before.
-
-**One behavioural change:** an unknown `engine` string now falls back to `VanillaAdapter` (skip
-orphan check) instead of `MkDocsAdapter`. If your `zenzic.toml` specifies a custom engine name
-that mapped to MkDocs behaviour, add the explicit `engine = "mkdocs"` declaration.
-
-### From v0.4.0-alpha.1
-
-The `--format` CLI flag is unchanged. The internal `format` parameter in `check_all`, `score`,
-and `diff` Python APIs has been renamed to `output_format` — update any programmatic callers.
-
----
-
-## Checksums and verification
-
-```text
-zenzic check all # self-dogfood: 7/7 OK
-pytest # 706 passed, 0 failed
-coverage # ≥ 80% branch (hard gate)
-mutation score # 86.7% (242/279 killed on rules.py)
-ruff check . # 0 violations
-mypy src/ # 0 errors
-mkdocs build --strict # 0 warnings
-```
-
----
+- [ ] All verification steps in §§ 3.1–3.4 completed
+- [ ] Documentation parity matrix §4 confirmed correct
+- [ ] Exit code contract §5 verified in code
+- [ ] Sandbox self-check §6 passed manually
+- [ ] `INTERNAL_GLOSSARY.toml` reviewed and approved
+- [ ] No open blocking issues
-*Zenzic v0.4.0 is released under the Apache-2.0 license.*
-*Built and maintained by [PythonWoods](https://github.com/PythonWoods).*
+**Decision:** ☐ Approve rc1 promotion ☐ Defer — open issues remain
---
-Based in Italy 🇮🇹 | Committed to the craft of Python development.
-Contact:
+*"Una Release Candidate non è un premio per aver finito i task, è una promessa di
+stabilità che facciamo all'utente."*
+— Senior Tech Lead
diff --git a/docs/adr/003-discovery-logic.md b/docs/adr/003-discovery-logic.md
new file mode 100644
index 0000000..6a66f2c
--- /dev/null
+++ b/docs/adr/003-discovery-logic.md
@@ -0,0 +1,151 @@
+# ADR 003: Root Discovery Protocol (RDP)
+
+**Status:** Active (amended by ZRT-005, 2026-04-08)
+**Deciders:** Architecture Lead
+**Date:** 2026-03-01
+**Amendment:** 2026-04-08
+
+---
+
+## Context
+
+Zenzic does not operate on isolated files. Every check it runs — link
+validation, orphan detection, asset resolution — is relative to a logical
+entity called the **Workspace**. The Workspace has a single authoritative
+boundary: the **project root**.
+
+Without a known root, Zenzic cannot:
+
+- Resolve absolute-style internal links (`/docs/page.md`) to physical files.
+- Locate `zenzic.toml` or a fallback engine config (`mkdocs.yml`, `zensical.toml`).
+- Enforce the Virtual Site Map (VSM) perimeter — the oracle that determines
+ what is a valid page and what is a Ghost Route.
+- Avoid accidentally indexing files that belong to a parent project,
+ a sibling repository, or the system root.
+
+The root discovery mechanism must therefore be **deterministic**, **safe by
+default**, and **engine-neutral** (independent of MkDocs, Zensical, or any
+other build toolchain).
+
+---
+
+## Decision
+
+`find_repo_root()` in `src/zenzic/core/scanner.py` walks upward from the
+current working directory, checking each ancestor for one of two **root
+markers** (first match wins):
+
+| Marker | Rationale |
+|--------|-----------|
+| `.git/` | Universal VCS signal. If a `.git` directory exists, the user has explicitly defined a repository boundary. Zenzic respects this boundary as the project perimeter. |
+| `zenzic.toml` | Zenzic's own configuration file. Its presence is an unambiguous declaration that this directory is the analysis root, even in non-VCS environments. |
+
+`mkdocs.yml`, `pyproject.toml`, and other engine-specific files are
+deliberately **excluded** from root markers. Including them would couple the
+discovery mechanism to a specific build engine, violating Pillar 1
+(*Lint the Source, not the Build*).
+
+If no marker is found in any ancestor, `find_repo_root()` raises a
+`RuntimeError` with an actionable message — it never silently defaults to the
+filesystem root.
+
+---
+
+## Rationale
+
+### 1. Safety: Preventing Accidental Massive Indexing
+
+A naive implementation that defaults to the current directory when no marker
+is found would allow a user invoking `zenzic check all` from `/home/user/` to
+inadvertently index their entire home directory. The strict failure mode is
+an **opt-out-of-danger** default: Zenzic refuses to act until the user
+establishes a perimeter.
+
+### 2. Consistency: Future `.gitignore` Support
+
+Using `.git` as the root anchor aligns Zenzic's workspace boundary with the
+VCS boundary. This is a prerequisite for any future feature that needs to
+parse `.gitignore` (e.g. automatic exclusion of `site/`, `dist/`, or
+generated build artifacts listed there).
+
+### 3. User Experience: Predictable, Loud Failure
+
+An ambiguous root produces incorrect results silently. A loud failure at
+startup — before any file is touched — is preferable to a scan that reports
+phantom violations or misses files because the root was resolved to the wrong
+ancestor. The error message includes the CWD and an explicit remediation
+hint.
+
+### 4. Engine Neutrality
+
+`.git` and `zenzic.toml` are both engine-neutral markers. The same root
+discovery logic works identically whether the project is built with MkDocs,
+Zensical, Hugo, or plain Pandoc. This preserves the core invariant that
+Zenzic's behaviour is independent of the build toolchain.
+
+---
+
+## Consequences
+
+- **Positive:** Every code path that calls `find_repo_root()` is guaranteed
+ to receive a valid, bounded directory or raise before any I/O occurs.
+- **Positive:** Ghost Route logic and VSM construction have a stable anchor.
+- **Negative (pre-amendment):** The `zenzic init` command, whose purpose is
+ to *create* the `zenzic.toml` root marker, could not be run in a directory
+ that had neither `.git` nor `zenzic.toml`. This was the **Bootstrap
+ Paradox** (ZRT-005).
+
+---
+
+## Amendment — ZRT-005: The Genesis Fallback (2026-04-08)
+
+**Problem:** `zenzic init` is the bootstrap command for new projects. Its
+entire purpose is to create the `zenzic.toml` root marker. Requiring a root
+marker to *already exist* before `init` can run is a Catch-22.
+
+**Resolution:** `find_repo_root()` gains a keyword-only parameter:
+
+```python
+def find_repo_root(*, fallback_to_cwd: bool = False) -> Path:
+ ... # walk upward from CWD; raise or return cwd based on flag
+```
+
+When `fallback_to_cwd=True` and no root marker is found, the function returns
+`Path.cwd()` instead of raising. This is called the **Genesis Fallback**.
+
+**Authorisation scope:** The Genesis Fallback is a single-point exemption.
+Only the `init` command passes `fallback_to_cwd=True`. Every other command
+(`check`, `scan`, `score`, `serve`, `clean`) retains the strict default
+(`fallback_to_cwd=False`) and will continue to fail loudly outside a project
+perimeter.
+
+```python
+# src/zenzic/cli.py — the only permitted call site for fallback_to_cwd=True
+@app.command()
+def init(plugin=None, force=False):
+ repo_root = find_repo_root(fallback_to_cwd=True) # Genesis Fallback
+ ...
+
+# Every other command — strict perimeter enforcement
+@app.command()
+def check(target=None, strict=False):
+ repo_root = find_repo_root() # raises outside a repo — correct
+ ...
+```
+
+**Security note:** The Genesis Fallback does **not** weaken the perimeter
+for analysis commands. `zenzic check all` run from `/home/user/` with no
+`.git` ancestor will still raise `RuntimeError`. The fallback is restricted
+to the one command that is explicitly designed to establish a perimeter from
+scratch.
+
+---
+
+## References
+
+- `src/zenzic/core/scanner.py` — `find_repo_root()` implementation
+- `src/zenzic/cli.py` — `init` command, sole consumer of `fallback_to_cwd=True`
+- `tests/test_scanner.py` — `test_find_repo_root_genesis_fallback`,
+ `test_find_repo_root_genesis_fallback_still_raises_without_flag`
+- `tests/test_cli.py` — `test_init_in_fresh_directory_no_git`
+- `CONTRIBUTING.md` — Core Laws → Root Discovery Protocol
diff --git a/docs/arch/vsm_engine.md b/docs/arch/vsm_engine.md
new file mode 100644
index 0000000..06b5cd3
--- /dev/null
+++ b/docs/arch/vsm_engine.md
@@ -0,0 +1,414 @@
+---
+icon: lucide/map
+---
+
+
+
+
+# VSM Engine — Architecture & Resolution Protocol
+
+> *"The VSM does not know where a file is. It knows where a file goes."*
+
+This document describes the Virtual Site Map engine, the `ResolutionContext`
+object introduced in v0.5.0a4, and the **Context-Free Anti-Pattern** that led
+to ZRT-004. Any developer writing or reviewing VSM-aware rules must read this
+page before merging.
+
+---
+
+## 1. What the VSM Is (and Is Not)
+
+The Virtual Site Map (VSM) is a pure in-memory projection of what the build
+engine will serve:
+
+```python
+VSM = dict[str, Route] # canonical URL → Route
+```
+
+A `Route` contains:
+
+| Field | Type | Meaning |
+|-------|------|---------|
+| `url` | `str` | Canonical URL, e.g. `/guide/install/` |
+| `source` | `str` | Relative source path, e.g. `guide/install.md` |
+| `status` | `str` | `REACHABLE` / `ORPHAN_BUT_EXISTING` / `IGNORED` / `CONFLICT` |
+| `anchors` | `frozenset[str]` | Heading slugs pre-computed from the source |
+
+The VSM is **not** a filesystem view. `Route.url` is the address a browser
+would request, not the address a file system `open()` would accept. A file can
+exist on disk (`Path.exists() == True`) and be `IGNORED` in the VSM. A URL can
+be `REACHABLE` in the VSM and have no file on disk (Ghost Routes).
+
+**Corollary:** Any code that validates links by calling `Path.exists()` inside
+a rule is wrong by definition. The VSM is the oracle; the filesystem is not.
+
+---
+
+## 2. Route Status Reference
+
+```mermaid
+flowchart TD
+ classDef ok fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#d1fae5
+ classDef warn fill:#3b1d00,stroke:#d97706,stroke-width:2px,color:#fef3c7
+ classDef err fill:#1c0a0a,stroke:#dc2626,stroke-width:2px,color:#fee2e2
+ classDef info fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#e2e8f0
+
+ R["REACHABLE"]:::ok
+ O["ORPHAN_BUT_EXISTING"]:::warn
+ I["IGNORED"]:::info
+ C["CONFLICT"]:::err
+
+ R -- "listed in nav: OR Ghost Route" --- R
+ O -- "on disk, absent from nav:" --- O
+ I -- "README.md, _private/" --- I
+ C -- "two .md files → same URL" --- C
+```
+
+| Status | Set by | Link to this status |
+|--------|--------|---------------------|
+| `REACHABLE` | nav listing, Ghost Route, locale shadow | ✅ Valid |
+| `ORPHAN_BUT_EXISTING` | file on disk, absent from nav | ⚠️ Z002 warning |
+| `IGNORED` | README not in nav, excluded patterns | ❌ Z001 error |
+| `CONFLICT` | two sources → same canonical URL | ❌ Z001 error |
+
+---
+
+## 3. URL Resolution: The Pipeline
+
+Converting a raw Markdown href (`../guide/install.md`) to a canonical URL
+(`/guide/install/`) requires three transformations, applied in sequence:
+
+```mermaid
+flowchart LR
+ classDef step fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#e2e8f0
+ classDef gate fill:#0f172a,stroke:#f59e0b,stroke-width:2px,color:#e2e8f0,shape:diamond
+ classDef out fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#d1fae5
+ classDef bad fill:#1c0a0a,stroke:#dc2626,stroke-width:2px,color:#fee2e2
+
+ A["Raw href\n'../guide/install.md'"]
+ B["① Normalise\nurlsplit + unquote\nbackslash → /"]:::step
+ C{"② Context check\nhas .. AND context?"}:::gate
+ D["③ os.path.normpath\nrelative to source_dir"]:::step
+ E{"④ Boundary check\nstays within docs_root?"}:::gate
+ F["⑤ Clean-URL transform\n strip .md / index\n prepend /, append /"]:::step
+ G["/guide/install/"]:::out
+ H["None\n(skip, do not report)"]:::bad
+
+ A --> B --> C
+ C -->|"yes"| D --> E
+ C -->|"no (root-relative)"| F
+ E -->|"yes"| F --> G
+ E -->|"no (escapes root)"| H
+```
+
+### Step ①: Normalise
+
+Strip query strings and percent-encoding artefacts:
+
+```python
+parsed = urlsplit(href)
+path = unquote(parsed.path.replace("\\", "/")).rstrip("/")
+```
+
+### Step ②–③: Context-Aware Relative Resolution (v0.5.0a4+)
+
+If the href contains `..` segments **and** a `ResolutionContext` is provided,
+the path is resolved relative to the source file's directory:
+
+```python
+if source_dir is not None and docs_root is not None and ".." in path:
+ raw_target = os.path.normpath(str(source_dir) + os.sep + path.replace("/", os.sep))
+```
+
+Without context (backwards-compatible path), the `..` segments are carried
+forward as-is into the clean-URL transform. This is correct for hrefs that do
+*not* traverse upward (`../sibling.md` from `docs/index.md` is unambiguous)
+but wrong for hrefs from deeply nested source files (see ZRT-004 below).
+
+### Step ④: Boundary Check
+
+```python
+def _to_canonical_url(href: str, source_dir=None, docs_root=None):
+ ...
+ root_str = str(docs_root)
+ if not (raw_target == root_str or raw_target.startswith(root_str + os.sep)):
+ return None # path escapes docs_root — Shield boundary
+```
+
+A path that escapes `docs_root` is not a broken link — it is a potential
+traversal attack. It returns `None`, which is silently skipped by the caller.
+No Z001 finding is emitted. No exception is raised.
+
+### Step ⑤: Clean-URL Transform
+
+```python
+def _to_canonical_url(href: str, source_dir=None, docs_root=None):
+ ...
+ if path.endswith(".md"):
+ path = path[:-3]
+
+ parts = [p for p in path.split("/") if p]
+ if parts and parts[-1] == "index":
+ parts = parts[:-1]
+
+ return "/" + "/".join(parts) + "/"
+```
+
+---
+
+## 4. ResolutionContext — The Context Protocol
+
+### Definition
+
+```python
+@dataclass(slots=True)
+class ResolutionContext:
+ """Source-file context for VSM-aware rules.
+
+ Attributes:
+ docs_root: Absolute path to the docs/ directory.
+ source_file: Absolute path of the Markdown file being checked.
+ """
+ docs_root: Path
+ source_file: Path
+```
+
+### Why It Exists
+
+Before v0.5.0a4, `VSMBrokenLinkRule._to_canonical_url()` was a `@staticmethod`.
+It received only `href: str`. This is the **Context-Free Anti-Pattern**.
+
+A pure function that converts a relative href to an absolute URL needs to know
+two things:
+
+1. **Where does the href start from?** (the source file's directory)
+2. **What is the containment boundary?** (the docs root)
+
+A static method cannot have this knowledge. Therefore, it silently produced
+wrong results for any file not at the docs root.
+
+### The Context-Free Anti-Pattern
+
+> **Definition:** A method that converts a relative path to an absolute URL
+> without receiving information about the origin of that relative path.
+
+Examples of the anti-pattern:
+
+```python
+# ❌ ANTI-PATTERN: static method, no origin context
+@staticmethod
+def _to_canonical_url(href: str) -> str | None:
+ path = href.rstrip("/")
+ ... # what directory is href relative to? Unknown.
+
+# ❌ ANTI-PATTERN: module-level function with only the href
+def resolve_href(href: str) -> str | None:
+ ... # same problem
+
+# ❌ ANTI-PATTERN: assuming href is relative to docs root
+def check_vsm(self, file_path, text, vsm, anchors_cache):
+ # file_path is docs/a/b/page.md
+ # href is ../sibling.md
+ # result is /sibling/, but correct answer is /a/sibling/
+ url = self._to_canonical_url(href)
+```
+
+The correct pattern:
+
+```python
+# ✅ CORRECT: instance method with explicit context
+def _to_canonical_url(
+ self,
+ href: str,
+ source_dir: Path | None = None, # where the href originates
+ docs_root: Path | None = None, # containment boundary
+) -> str | None:
+ ...
+```
+
+### How to Pass Context to check_vsm
+
+The engine passes context when `run_vsm` is called by the coordinator:
+
+```python
+# In scan_docs_references() or the plugin:
+context = ResolutionContext(
+ docs_root=Path(config.docs_dir),
+ source_file=md_file,
+)
+rule_engine.run_vsm(md_file, text, vsm, anchors_cache, context=context)
+```
+
+Inside a rule that overrides `check_vsm`:
+
+```python
+def check_vsm(
+ self,
+ file_path: Path,
+ text: str,
+ vsm: Mapping[str, Route],
+ anchors_cache: dict[Path, set[str]],
+ context: ResolutionContext | None = None, # ← always accept
+) -> list[Violation]:
+ for url, lineno, raw_line in _extract_inline_links_with_lines(text):
+ target_url = self._to_canonical_url(
+ url,
+ source_dir=context.source_file.parent if context else None,
+ docs_root=context.docs_root if context else None,
+ )
+```
+
+### Backwards Compatibility
+
+`context` defaults to `None` in both `BaseRule.check_vsm` and
+`AdaptiveRuleEngine.run_vsm`. Existing rules that do not accept the parameter
+will receive a `TypeError` wrapped in a `RULE-ENGINE-ERROR` finding — they
+will not crash the scan, but they will not benefit from context-aware
+resolution either.
+
+**Migration checklist for existing VSM-aware rules:**
+
+1. Add `context: "ResolutionContext | None" = None` to `check_vsm` signature.
+2. Pass `source_dir` and `docs_root` from `context` to any url-resolving helper.
+3. Add a test case with a `../../`-relative href from a nested file.
+
+---
+
+## 5. Worked Examples
+
+### Example A: Simple relative href (context not needed)
+
+```text
+Source: docs/guide.md
+href: install.md
+```
+
+Step ① → `install`
+Step ② → no `..`, skip context
+Step ⑤ → `/install/`
+VSM lookup → `vsm.get("/install/")`
+
+Context makes no difference here. The href is already root-relative-safe.
+
+---
+
+### Example B: Single `..` from a subdirectory (context required)
+
+```text
+Source: docs/api/reference.md
+href: ../guide/index.md
+```
+
+**Without context (legacy behaviour):**
+
+Step ① → `../guide/index`
+Step ⑤ → `/../guide` → parts `['..', 'guide']` → `/guide/` ← *wrong path arithmetic*
+
+**With `ResolutionContext(docs_root=/docs, source_file=/docs/api/reference.md)`:**
+
+Step ③ → `normpath("/docs/api" + "/../guide/index")` → `/docs/guide/index`
+Step ④ → `/docs/guide/index` starts with `/docs/` ✅
+Step ⑤ → `relative_to(/docs)` → `guide/index` → strip `index` → `/guide/`
+VSM lookup → `vsm.get("/guide/")` ✅ correct
+
+---
+
+### Example C: Traversal escape (blocked at boundary)
+
+```text
+Source: docs/api/reference.md
+href: ../../../../etc/passwd
+```
+
+Step ③ → `normpath("/docs/api" + "/../../../../etc/passwd")` → `/etc/passwd`
+Step ④ → `/etc/passwd` does **not** start with `/docs/` → return `None`
+Caller receives `None` → `continue` → zero findings emitted ← correct
+
+---
+
+### Example D: Ghost Route (reachable without a file)
+
+```text
+href: /it/
+```
+
+Step ① → path `/it`, not a relative href → external check skips it
+(Ghost Routes appear in the VSM as `REACHABLE`; the rule validates the URL
+string directly against the VSM after the href is converted — if the URL is
+already canonical, no conversion is needed.)
+
+---
+
+## 6. VSM-Aware Rule Contract
+
+Every rule that overrides `check_vsm` must satisfy this contract:
+
+| Requirement | Rationale |
+|-------------|-----------|
+| Accept `context: ResolutionContext \| None = None` | Backwards-compat + context forwarding |
+| Do not call `Path.exists()` | VSM is the oracle, filesystem is not |
+| Do not mutate `vsm` or `anchors_cache` | Shared across all rules; mutation causes race conditions in parallel mode |
+| Return `Violation`, not `RuleFinding` | `run_vsm` converts via `v.as_finding()` |
+| Handle `context=None` gracefully | Context may be absent in tests or old callers |
+
+---
+
+## 7. Anti-Pattern Catalogue
+
+The following patterns are **banned** in `core/rules.py` and `core/validator.py`:
+
+| Pattern | Why banned | Alternative |
+|---------|-----------|-------------|
+| `@staticmethod def _to_canonical_url(href)` | Cannot receive origin context | Instance method with `source_dir`, `docs_root` |
+| `Path.exists()` inside `check_vsm` | Violates Zero I/O contract | `vsm.get(url) is not None` |
+| `Path.resolve()` inside a rule | Makes I/O call | `os.path.normpath()` (pure string math) |
+| `open()` inside a rule | Violates Zero I/O contract | All content in `text` arg |
+| `vsm[url]` (direct subscript) | Raises `KeyError` on missing URL | `vsm.get(url)` |
+
+---
+
+## 8. Testing VSM-Aware Rules
+
+### Minimum test matrix
+
+Every `check_vsm` implementation must be tested with:
+
+| Case | Description |
+|------|-------------|
+| Root-level href | `guide.md` from `docs/index.md` |
+| Single `..` with context | `../sibling.md` from `docs/subdir/page.md` |
+| Multi-level `..` with context | `../../c/t.md` from `docs/a/b/page.md` |
+| Traversal escape | `../../../../etc/passwd` from `docs/api/ref.md` |
+| Absent from VSM | link to a URL not in the VSM → Z001 |
+| `ORPHAN_BUT_EXISTING` | link to an orphan route → Z002 |
+| `context=None` | all assertions must pass with no context |
+
+### Test fixture pattern
+
+```python
+def _make_vsm(*urls: str, status: str = "REACHABLE") -> dict[str, Route]:
+ return {
+ url: Route(url=url, source=f"{url.strip('/')}.md", status=status)
+ for url in urls
+ }
+
+# Context for a file nested two levels deep
+ctx = ResolutionContext(
+ docs_root=Path("/docs"),
+ source_file=Path("/docs/api/v2/reference.md"),
+)
+
+violations = rule.check_vsm(
+ Path("/docs/api/v2/reference.md"),
+ "[Guide](../../guide/index.md)",
+ _make_vsm("/guide/"),
+ {},
+ ctx,
+)
+assert violations == []
+```
+
+---
+
+*Document status: current as of v0.5.0a4. Update when `ResolutionContext` gains
+new fields or the boundary-check logic changes.*
diff --git a/docs/assets/screenshots/screenshot-blood.svg b/docs/assets/screenshots/screenshot-blood.svg
new file mode 100644
index 0000000..6decaa2
--- /dev/null
+++ b/docs/assets/screenshots/screenshot-blood.svg
@@ -0,0 +1,139 @@
+
diff --git a/docs/assets/screenshots/screenshot-blood.svg.license b/docs/assets/screenshots/screenshot-blood.svg.license
new file mode 100644
index 0000000..73c93a8
--- /dev/null
+++ b/docs/assets/screenshots/screenshot-blood.svg.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2026 PythonWoods
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/docs/assets/screenshots/screenshot-circular.svg b/docs/assets/screenshots/screenshot-circular.svg
new file mode 100644
index 0000000..bb8fa10
--- /dev/null
+++ b/docs/assets/screenshots/screenshot-circular.svg
@@ -0,0 +1,184 @@
+
diff --git a/docs/assets/screenshots/screenshot-circular.svg.license b/docs/assets/screenshots/screenshot-circular.svg.license
new file mode 100644
index 0000000..73c93a8
--- /dev/null
+++ b/docs/assets/screenshots/screenshot-circular.svg.license
@@ -0,0 +1,3 @@
+SPDX-FileCopyrightText: 2026 PythonWoods
+
+SPDX-License-Identifier: Apache-2.0
diff --git a/docs/assets/screenshots/screenshot.svg b/docs/assets/screenshots/screenshot.svg
index 7474a19..7893ab1 100644
--- a/docs/assets/screenshots/screenshot.svg
+++ b/docs/assets/screenshots/screenshot.svg
@@ -1,4 +1,4 @@
-