# 05 — Redirects, Search Index, Page Snapshot e Change-Ledger/Content-Mirror Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em `emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`), 19-08-2026. Contexto herdado de `00-ARQUITECTURA.md` (cadeia de arranque, contrato `emcp_tools_register_ability()`, dispatcher compacto) e `skill://emcp-tools` (postura de segurança activa/desligada por site) — não repetidos aqui salvo onde relevante para os quatro subsistemas abaixo. Este documento cobre **quatro subsistemas independentes** que partilham uma característica: são todos **"always-on"** (registados incondicionalmente em `class-ability-registrar.php`, sem gate de plugin de terceiros nem de licença Pro) — **excepto o Redirect Manager**, que é o único dos quatro atrás de um module gate (`redirects`, free, activo por omissão). Isto é um sinal de design deliberado: o autor considera pesquisa de conteúdo, snapshot de página, ledger de alterações e mirror de conteúdo **infra-estrutura nuclear** do plugin, não funcionalidades opcionais — mesmo o Redirect Manager, apesar de "module-gated", vem activo por omissão em todos os sites verificados. ## 0. Nota sobre um ficheiro fora do agrupamento temático: `class-url-guard.php` `includes/class-url-guard.php` (`EMCP_Tools_Url_Guard`) foi incluído na lista de ficheiros desta tarefa mas **não pertence funcionalmente** a nenhum dos quatro subsistemas — é um **serviço SSRF partilhado**, usado pelas tools de sideload de imagem/SVG (doc 09) e, desde a v3.2.0, por uma validação mais estrita usada por uma tool `web_fetch` de AI Chat (Pro, fora deste build). Documentado em separado na §6 por completude do ficheiro pedido, mas não faz parte da arquitectura de Redirects/Search/Snapshot/Ledger em si. --- ## 1. Redirect Manager **Classe de abilities:** `EMCP_Tools_Redirect_Abilities` (`includes/abilities/class-redirect-abilities.php`) **Condição de registo** (copiada de `class-ability-registrar.php`, linhas 162-169): ```php // Redirect Manager abilities (301/302 redirects + broken-link scan; no // Elementor). Gated on the Redirects module (on by default) — abilities // register before the module boots on init:5, so gate on is_enabled(). if ( class_exists( 'EMCP_Tools_Redirect_Module' ) && EMCP_Tools_Redirect_Module::is_enabled() ) { $redirects = new EMCP_Tools_Redirect_Abilities(); $redirects->register(); $this->ability_names = array_merge( $this->ability_names, $redirects->get_ability_names() ); } ``` **Ponto de design a reter:** as abilities registam-se em `wp_abilities_api_init`, que corre **antes** do módulo arrancar (`init:5`) — por isso o gate não pode depender de estado de instância do módulo; `EMCP_Tools_Redirect_Module::is_enabled()` tem de ser uma leitura **estática e sem efeitos secundários** (lê `emcp_tools_active_modules` directamente do option). Este é o padrão correcto para qualquer grupo de abilities gated a um módulo numa réplica: nunca depender da ordem de hooks do próprio módulo. Não é Elementor-dependente (funciona em qualquer site, mesmo sem Elementor activo). ### 1.1 Tools Todas as 5 tools partilham `permission_callback` = `check_manage_permission()` → `current_user_can('manage_options')` — **inclusive as de leitura** (`list-redirects`, `find-broken-links`), ao contrário dos outros três subsistemas deste documento que usam `edit_posts` para leitura. Reflecte que redirects tocam routing de produção com impacto directo em SEO — o autor optou por um limiar de permissão mais alto mesmo para inspecção. | Tool | Input schema (resumo) | O que faz | `meta.annotations` | |---|---|---|---| | `list-redirects` | `enabled?:bool`, `search?:string`, `per_page?:int` (1-500, def 100), `page?:int` (def 1) | Lista redirects da tabela `{prefix}emcp_redirects` com filtro/paginação; devolve `{redirects[], total}`. | readonly, não-destructive, idempotent | | `create-redirect` | `source*:string`, `target?:string` **OU** `target_post_id?:int` (mutuamente exclusivos), `status_code?:enum[301,302]` (def 301), `ignore_query?:bool` (def true). `required:[source]` | Cria um redirect 301/302. **Avisa mas não bloqueia** quando o `source` já resolve para uma página publicada e viva (`shadow_warning()` — `url_to_postid()` + `get_post_status()==publish` → devolve `warning` no output em vez de recusar). Toda escrita passa por `EMCP_Tools_Change_Recorder::record_redirect()`. | não-readonly, não-destructive, não-idempotent | | `update-redirect` | `id*:int` + qualquer subconjunto de `source/target/target_post_id/status_code/ignore_query/enabled`. `required:[id]` | Actualiza só os campos fornecidos (patch parcial). Grava o antes (`$prior`) antes de aplicar, para o ledger. | não-readonly, não-destructive, **idempotent=true** | | `delete-redirect` | `id*:int`. `required:[id]` | Elimina por id. Reversível a partir da tab History. | não-readonly, **destructive=true**, não-idempotent | | `find-broken-links` | `max_posts?:int` (1-2000, def 200), `max_seconds?:int` (1-60, def 10) | Varre `post_content` de todos os post types públicos e publicados, extrai `href=` via regex, classifica cada link interno como `external/ok/dead/redirected` contra as fontes de redirect activas. **Só leitura — propõe, não corrige nada.** Limitado por posts E por tempo (`microtime()`), devolve `partial:true` se algum limite disparar a meio. | readonly, não-destructive, idempotent | ### 1.2 `EMCP_Tools_Redirect_Handler` — o hook de front-end `includes/redirects/class-redirect-handler.php`. Regista-se em `template_redirect` prioridade **1** (o mais cedo possível, antes de qualquer templating de 404). `should_skip()` recusa disparar em `is_admin()`, `wp_doing_cron()`, `wp_is_json_request()`, e por regex em `wp-admin|wp-json|wp-login.php` no `REQUEST_URI` cru (defesa redundante ao `is_admin()`/REST check para o caso de esses helpers ainda não estarem disponíveis nesta fase tão cedo do ciclo de vida). Hot path: `normalize_path($uri)` → `find_by_source()` (lookup indexado único) → `resolve_target()` → `would_loop()` guard → forward do query string original SE o target não tiver já um `?` → `record_hit()` (incrementa contador+timestamp) → `wp_redirect($target,$code)` + `exit`. **Gotcha observado (não documentado no código, inferido por leitura cruzada):** o campo `ignore_query` é capturado e persistido na tabela, mas **`maybe_redirect()` nunca o lê**. O matching é sempre por `source_path` normalizado (que já descarta a query string em `normalize_path()`, através de `wp_parse_url($s)['path']`), logo a query é **sempre** ignorada para efeitos de correspondência, independentemente do valor de `ignore_query`. O que o handler efectivamente usa a query original para é só reencaminhá-la para o alvo quando este não já tiver a sua própria (`?query`). Ou este campo é vestigial (pensado para um modo de correspondência exacta com query que nunca chegou a ser implementado), ou é intencionalmente sempre-true na prática e o toggle serve outro propósito ainda não coberto por estes ficheiros (ex.: UI apenas). **Numa réplica, decidir explicitamente** um dos dois: implementar correspondência exacta por query quando `ignore_query=false`, ou remover o campo. ### 1.3 `EMCP_Tools_Redirect_Store` — tabela própria + CRUD + normalização `includes/redirects/class-redirect-store.php`. Segue o mesmo padrão de storage do Search Index (§2): tabela custom `{prefix}emcp_redirects`, `DB_VERSION` const (`1`) + option `emcp_tools_redirects_db_version`, instalação via `dbDelta()` gated em `init:20` (`maybe_install()`, corre só quando `get_option(DB_VERSION_OPTION,0) < DB_VERSION`). Schema: ```sql id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, source_path VARCHAR(191) NOT NULL, -- UNIQUE KEY source_unique target TEXT NOT NULL, target_post_id BIGINT UNSIGNED NULL, status_code SMALLINT NOT NULL DEFAULT 301, match_type VARCHAR(20) NOT NULL DEFAULT 'exact', -- ver nota abaixo ignore_query TINYINT(1) NOT NULL DEFAULT 1, enabled TINYINT(1) NOT NULL DEFAULT 1, hits BIGINT UNSIGNED NOT NULL DEFAULT 0, last_hit DATETIME NULL, notes TEXT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY enabled_idx (enabled) ``` **`match_type` é escrito sempre como `'exact'`** por `create()`/`row_for_write()` — nenhum código nestes ficheiros lê ou ramifica sobre este valor. É claramente uma coluna preparada para um futuro modo de correspondência (prefixo/regex/wildcard) que ainda não existe — outro campo "de intenção futura" como o `ignore_query`. **Helpers puros (sem BD, testáveis sem WordPress a correr, comentário explícito no header do ficheiro):** - `normalize_path()` — normaliza URL/path para path comparável home-relative: extrai só `path` via `wp_parse_url`, remove prefixo de subdirectório (`home_url('/')`), descodifica (`rawurldecode`), lowercase, colapsa `//` repetidos, garante 1 slash inicial, remove slash final; raiz mantém-se `/`. - `would_loop($source,$target)` — `normalize_path(source) === normalize_path(target)`. - `resolve_target($row)` — quando `target_post_id>0`, resolve o permalink **ao vivo** (`get_permalink()`) em vez de guardar a URL estática — sobrevive a mudanças de slug do próprio post de destino; devolve `''` (redirect tratado como inactivo) se o post já não existir. **Validações em `create()`** (ordem exacta): source vazio/raiz rejeitado → source>191 chars rejeitado → `target` E `target_post_id` em simultâneo rejeitado (`ambiguous_target`) → nenhum dos dois rejeitado (`missing_target`) → self-loop rejeitado (`redirect_loop`) → source duplicado rejeitado (`duplicate_source`, via `find_by_source()` antes do INSERT — não confia só na UNIQUE KEY da BD). **`rollback($rb)` — o applier do tipo `redirect-row` do ledger** (chamado a partir de `EMCP_Tools_Change_Log::apply_rollback()`, §4.4): 3 formas conforme a acção original — `create` → `before:{id}` → apaga a linha; `update`/`delete` → `before:{row:{...linha completa}}` → restaura/reinsere a linha completa preservando o `id` original (`row_for_write()` mapeia todas as colunas incluindo `id`, com formatos `%d/%s/%s/%d/…` próprios para insert vs update, via `write_formats()`/`write_formats_no_id()`). **Nota arquitectural importante:** este applier **não vive no dispatcher central** (`class-change-log.php`) — vive na própria classe de domínio (`Redirect_Store`), e o dispatcher central limita-se a `EMCP_Tools_Redirect_Store::rollback($rb)` dentro do seu `switch`. Ver §4 para o significado disto no design geral do ledger. --- ## 2. Índice de pesquisa de conteúdo (Content Search Index) **Classe de abilities:** `EMCP_Tools_Search_Abilities` (`includes/abilities/class-search-abilities.php`) — sempre registada (linhas 186-189 do registrar: `// Content search — lexical index over pages/templates/widgets/globals (always-on).`), sem qualquer `class_exists()`/module gate. ### 2.1 Tools Ambas usam `check_read_permission()` → `current_user_can('edit_posts')`. **Diferença notável face ao Redirect Manager:** nenhuma das duas chamadas `emcp_tools_register_ability()` inclui um bloco `meta` explícito (nem `output_schema`) — ao contrário de todas as tools do Redirect Manager. Como `emcp_tools_register_ability()` não injecta um `meta.annotations` por omissão visível nestes ficheiros, o comportamento efectivo (readonly/destructive) para clientes MCP que inspeccionam anotações fica indefinido/omisso para estas duas tools — inconsistência de estilo entre grupos de abilities do mesmo plugin, a evitar numa réplica (declarar sempre `meta.annotations`, mesmo quando óbvio). | Tool | Input schema (resumo) | O que faz | |---|---|---| | `search-content` | `query*:string`, `types?:array`, `limit?:int` (def 20). `required:[query]` | Pesquisa o índice léxico materializado, devolve `{query, results[], count}`. Cada resultado: `{object_type, object_id, title, score, snippet, meta}`. | | `reindex-search` | `types?:array` (vazio = todos) | Reconstrói o índice (total ou parcial), devolve `{indexed:{tipo:contagem}, total}`. | ### 2.2 `EMCP_Tools_Search_Index` — a tabela + os document builders `includes/class-search-index.php`. Tabela custom `{prefix}emcp_search_index`, `DB_VERSION=1`, option `emcp_tools_search_index_db_version`, instalação `dbDelta()` gated em `init:20`. ```sql id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, object_type VARCHAR(20) NOT NULL, object_id VARCHAR(64) NOT NULL, title TEXT NOT NULL, content LONGTEXT NOT NULL, tokens LONGTEXT NOT NULL, -- ver nota "coluna morta" abaixo meta LONGTEXT NULL, -- JSON updated_at INT NOT NULL, UNIQUE KEY object_unique (object_type, object_id), KEY object_type_idx (object_type) ``` **Achado — coluna `tokens` é escrita mas nunca lida:** `upsert()` calcula `EMCP_Tools_Search_Ranker::tokenize(title.' '.content)` e guarda-o em `tokens` a cada inserção/substituição, mas `search()` faz `SELECT object_type,object_id,title,content,meta` — **`tokens` não entra na query**. O `EMCP_Tools_Search_Ranker::rank()` retokeniza `title`+`content` **ao vivo**, a cada pesquisa, para todos os documentos do tipo filtrado. Isto é armazenamento morto: escreve-se trabalho computacional (tokenização) numa coluna que nunca é consultada, e o custo de tokenização repete-se em runtime a cada pesquisa em vez de ser amortizado. **Numa réplica:** ou remover a coluna, ou (melhor) usar FULLTEXT MySQL sobre `tokens` e evitar o full-table-scan + retokenização em PHP a cada pesquisa — a v1 "lexical" descrita no header do ranker é claramente um MVP consciente disto (comentário: "the embedding-backed rerank is a future upgrade layered on top of this"). **Hooks de manutenção** (`init()`): `init:20` instala; `save_post:30` reindexa incrementalmente; `deleted_post:10` remove do índice. `on_save_post()` — cadeia de guardas antes de reindexar: ignora autosave → ignora revisão → ignora se a tabela ainda não tem a versão instalada → **ignora se `EMCP_Tools_Data::elementor_documents_ready()` for falso** (guarda contra um fatal documentado como **issue #105**: o Elementor insere o seu kit por omissão durante a sua **própria** activação, um `save_post` que aterra aqui antes do document manager do Elementor existir — indexar nesse momento desreferenciaria um manager nulo e provocaria fatal na activação do Elementor) → se o post gravado for o **kit activo** (`elementor_active_kit` option), reindexa só os globais (`index_globals()`) em vez de o tratar como um "template" comum → senão, indexa como `template` (post_type `elementor_library`) ou `page` (`page`/`post` com `_elementor_edit_mode=builder`). `rebuild($types)` — 4 grupos independentes, cada um com `clear_type()` (DELETE por tipo) antes de reindexar: - **widgets** — `widget_documents()`: percorre `EMCP_Tools_Widget_Catalog::get()` (catálogo PHP estático, doc 02 — não vem de BD nem de posts), constrói `content` a partir de title+use_case+keywords+category+widget_type(normalizado)+nomes dos parâmetros. - **pages** — `WP_Query` sobre `page`/`post`, **todos os status** (`publish/draft/pending/private/future`), filtro `_elementor_edit_mode=builder`. - **templates** — `WP_Query` sobre `elementor_library`, mesmos status. - **globals** — lê `_elementor_page_settings` do kit activo (`elementor_active_kit`), indexa `system_colors`+`custom_colors` como `global_color` e `system_typography`+`custom_typography` como `global_font`; conteúdo inclui o valor (cor hex / nome da fonte) para que a pesquisa por valor também funcione. **`page_document()`** — reutiliza directamente os helpers puros do Page Snapshot (§3): `EMCP_Tools_Page_Snapshot::normalize_tree()` (para os tipos de widget usados, normalizados `-`/`_`→espaço), `content_stats()` (para o texto dos headings), `extract_tokens()` (ids de cores/classes globais em uso) — mais uma recolha recursiva de `label`s de elementos (`collect_labels()`). **Ponto de arquitectura a reter para a réplica:** o índice de pesquisa não tem a sua própria lógica de leitura de árvore Elementor — delega inteiramente à camada Page Snapshot, evitando duplicar o parsing de settings Elementor em dois sítios. ### 2.3 `EMCP_Tools_Search_Ranker` — TF-IDF léxico puro `includes/class-search-ranker.php`. **Zero dependência de WordPress** excepto um `apply_filters()` opcional guardado por `function_exists()` — pode correr em testes unitários puros sem framework nenhum, exactamente como o header documenta. - `tokenize()` — lowercase, split por `[^a-z0-9]+`, descarta tokens <2 chars, descarta uma stopword-list curada pequena (inglês; nada de PT-PT — relevante para uma réplica com conteúdo em português, onde esta lista teria de ser adaptada ou substituída). - `rank($docs,$query,$limit)` — TF-IDF campo-ponderado: `TITLE_BOOST=3.0` (ocorrências no título contam 3x face ao corpo), IDF calculado por `log(1 + (n-df+0.5)/(df+0.5))` (fórmula tipo BM25 mas sem o factor de saturação de frequência de termo completo — é uma aproximação simplificada, não BM25 puro), score final `sum((tf/(tf+1)) * idf)` por termo da query presente no documento. - **Seam explícito para reranking futuro:** `apply_filters('emcp_tools_search_rerank', $scored, $query)` corre **depois** da ordenação léxica e **antes** do `array_slice` ao limite — comentário no código di-lo directamente: "This is the seam for an embedding-backed reranker (a future Pro upgrade); the lexical order is the default." - `snippet()` — extrai ~120 chars à volta da primeira ocorrência do primeiro termo da query encontrado (não do termo com melhor score — é posicional, não semântico). --- ## 3. Page Snapshot **Classe de abilities:** `EMCP_Tools_Snapshot_Abilities` (`includes/abilities/class-snapshot-abilities.php`) — sempre registada (linhas 176-179 do registrar: `// Page Snapshot — always-on normalized page digest (read foundation).`), recebe `EMCP_Tools_Data` injectado no construtor (dependência partilhada com quase todo o resto do plugin, doc 01). ### 3.1 Tool | Tool | Input schema (resumo) | O que faz | |---|---|---| | `get-page-snapshot` | `post_id*:int`, `include?:array`, `sections?:array`, `fresh?:bool`. `required:[post_id]` | Devolve **um** digest normalizado da página em vez de forçar o agente a encadear `get-page-structure`+`get-global-settings`+`list-global-classes`+etc. | `permission_callback` = `check_read_permission()` → `edit_posts`. Também sem bloco `meta` explícito (mesmo padrão de omissão que o Search, §2.1). **`execute()`** detecta o builder da página com `detect_builder()` (estático, público): `is_elementor` (via `_elementor_edit_mode=builder` postmeta) → `'elementor'`; senão `has_blocks($content)` → `'gutenberg'`; senão `'classic'`. Este valor entra no objecto `post` devolvido e é passado ao builder (§3.2). ### 3.2 `EMCP_Tools_Page_Snapshot` — o builder `includes/class-page-snapshot.php`. Duas listas de secções: - **`CORE_SECTIONS`** (`post, structure, tokens, responsive, content, seo_lite, warnings`) — sempre computadas em processo, baratas, puras (nenhuma chamada de rede/loopback). - **`HEAVY_SECTIONS`** (`performance, a11y, seo`) — **opt-in** via `include`, cacheadas em transient 15 min (`emcp_snap_{post_id}_{section}`), bypass com `fresh:true`. **Achado — a árvore só é lida quando `builder==='elementor'`:** `build()` só chama `$this->data->get_page_data($post_id)` quando `$args['builder']==='elementor'`; para `gutenberg`/`classic` `$elements` fica `array()` vazio, e por isso `structure`, `tokens`, `responsive` e a maior parte de `content` saem **essencialmente vazios** para páginas não- Elementor, apesar de `detect_builder()` os identificar correctamente. `get-page-snapshot` é, na prática, uma ferramenta **Elementor-first**; uma implementação equivalente para Gutenberg exigiria o seu próprio tree-walker sobre `parse_blocks()` — não existe neste build. **Secção `performance`** (única heavy section livre no Free): exige `manage_options` (verificação extra dentro do próprio builder, independente do `permission_callback` da ability — defesa em profundidade), delega a `EMCP_Tools_Performance_Analyzer` (doc 07), resultado achatado a `{available, score, grade, recommendations[≤5]}`. **Secções `a11y`/`seo`** (Pro): resolvidas via o filtro `emcp_tools_page_snapshot_sections` — quando nada responde ao filtro (build Free), degradam para `{available:false, pro_gated:true}`. **Padrão de "seam" reutilizável numa réplica:** o core Free nunca sabe o que o Pro faz, só declara a forma do buraco (`available`/`pro_gated`) e deixa o filtro preenchê-lo se existir um overlay activo. **`seo_lite()`** — leitura gratuita e superficial de SEO: conta H1 a partir de `content`, lê chaves de postmeta conhecidas de Yoast/Rank Math/SEOPress em cascata (primeira não-vazia ganha) para `meta_title`/`meta_description`/`canonical`/`og_image`. Filtro `emcp_tools_page_snapshot_seo_lite` permite a um plugin que **não** guarda SEO em postmeta (ex.: All in One SEO, tabela própria) injectar os valores correctos — mesmo padrão de seam usado noutros pontos do plugin. **Helpers puros de árvore (reutilizados por §2 e potencialmente por qualquer outra tool que precise de "entender" uma árvore Elementor sem reescrever o parsing):** - `normalize_tree()` — recursivo, produz `{tree, counts}` com `containers`, `widgets`, `by_widget_type`, `max_depth`, `total_elements`; cada nó da árvore ganha `label` derivado de `element_label()`. - `element_label()` — primeiro campo não-vazio de `_title|title|text|editor|heading_title` nas settings, tags HTML removidas, cortado a 60 chars (`snippet()`). - `extract_tokens()` — cores/tipografia globais referenciadas via `__globals__` (regex `globals/colors?id=…` / `globals/typography?id=…`), classes `g-` (de `_css_classes`/ `classes` string OU `classes.value` array — dois formatos coexistentes, clássico vs atómico), fontes/cores hex "em uso" (heurística: chave de settings contém `font_family` ou `color` + valor casa `#hex`). - `detect_responsive()` — regex `_(tablet|mobile|laptop|widescreen|mobile_extra| tablet_extra)$` sobre as chaves de settings de cada nó. - `content_stats()` — outline de headings, contagem de palavras, imagens/links/botões, imagens sem alt. **Cobre tanto widgets clássicos como atómicos (Elementor 4.0+)** — os ramos atómicos (`e-heading`/`e-paragraph`/`e-button`/`e-image`) foram adicionados explicitamente com o comentário: *"Atomic (Elementor 4.0) widgets store their content as $$type-wrapped props under different keys than classic widgets, so the classic branches above miss them entirely (**issue #91**). Handle them here."* — um bug real, corrigido, citado no código; qualquer réplica que suporte Elementor 4.0 tem de replicar este desdobramento clássico+atómico em paralelo, não assumir que um cobre o outro. - `warnings()` — 4 cheiros estruturais: `no_h1`, `multiple_h1`, `deep_nesting` (depth≥6), `empty_container` (recursivo `has_empty_container()`). --- ## 4. Change-Ledger / Rollback (Transacções "AI-safe") Este é, tal como assinalado no pedido, **o subsistema mais valioso a replicar bem** — é a rede de segurança de **todas** as escritas do plugin (Elementor, filesystem, BD directa, posts/CPT, settings, redirects, utilizadores, ACF, media). Quatro classes cooperam: ``` EMCP_Tools_Transaction_Abilities → as 3 tools MCP (list-changes/get-change/rollback-change) EMCP_Tools_Change_Log → o ledger em si (option capado) + o DISPATCHER de rollback EMCP_Tools_Change_Recorder → a FACHADA que cada write-site chama para gravar "antes" EMCP_Tools_Change_Blobs → tabela SQL para before-images grandes fora do option ``` ### 4.1 `EMCP_Tools_Transaction_Abilities` — as 3 tools `includes/abilities/class-transaction-abilities.php`. Sempre registada (linhas 181-184 do registrar: `// AI-safe transactions — change ledger + rollback (always-on, write foundation).`). `permission_callback` para as 3 = `check_manage()` → `current_user_can('manage_options')`, com o comentário explícito no código: *"the ledger spans admin-grade fs/db targets"* — é o único dos 4 subsistemas deste documento (à parte o Redirect Manager) que exige `manage_options` mesmo para leitura. | Tool | Input schema (resumo) | O que faz | |---|---|---| | `list-changes` | `domain?:enum[elementor,filesystem,database]`, `rolled_back?:bool`, `reversible?:bool`, `limit?:int` (def 50) | Lista entradas mais recentes primeiro (`array_reverse`), cada uma com `id, ts, user_login, domain, action, target, summary, rolled_back, reversible, rollback` (o `rollback` devolvido é uma versão "leve" — ver `light_rollback()` abaixo). `reversible` é **derivado**: `!empty(rollback) && empty(rolled_back)`. | | `get-change` | `id*:string`. `required:[id]` | Devolve a entrada **completa** (incluindo o `rollback` ref inteiro, sem strip). | | `rollback-change` | `id*:string`, `force?:bool` (def false) | Desfaz a entrada. `force` salta o "conflict guard" (§4.3). | **`light_rollback()`** — usada só por `list-changes` (não por `get-change`): remove `before_rows`/`before`/`inserted_key` do ref de rollback antes de o incluir na resposta — evita que uma listagem de 50 entradas arraste payloads pesados (mesmo já offloadados para blob, o `blob_id` sozinho é leve, mas antes de existirem blobs os `before`/`before_rows` inline podiam ser grandes). **Nota:** o enum declarado no `input_schema` de `domain` (`elementor|filesystem|database`) é **mais estreito** do que os domínios realmente gravados pelo Recorder (`content, settings, redirect, users, acf, media` também existem — ver §4.2) — `execute_list()` faz uma comparação de string simples (`$e['domain']!==$domain`), não valida contra o enum, logo um cliente que ignore o schema declarado e passe `domain:"redirect"` **funciona na mesma**. Inconsistência schema-vs-implementação a corrigir numa réplica (alargar o enum para cobrir todos os domínios reais, ou documentar que o filtro aceita qualquer string). ### 4.2 `EMCP_Tools_Change_Log` — o ledger + o dispatcher de rollback `includes/class-change-log.php`. **O ledger em si não tem tabela SQL própria** — é `get_option('emcp_tools_changelog', [])`, um array PHP serializado, sem versionamento de schema (não precisa: é só um array de linhas leves). `MAX_COUNT=500`, `MAX_BYTES=2097152` (~2 MB) — `cap()` primeiro corta por contagem (`array_slice` às 500 mais recentes), depois itera a apagar a mais antiga (`array_shift`) enquanto o JSON codificado continuar acima do limite de bytes. **Toda linha descartada por `cap()`/`delete()`/`clear()` passa por `forget_blobs()`**, que apaga o `blob_id` referenciado em `EMCP_Tools_Change_Blobs` — sem isto, o offload de before-images pesados (§4.4) acumularia blobs órfãos indefinidamente. **A flag de supressão — o mecanismo central que evita recursão:** ```php public static $suppress = false; // estática, pública ``` Quando `true`, `record()` é um no-op imediato (`return ''`). `rollback()` liga-a (`self::$suppress = true`) **antes** de chamar `apply_rollback($rb)` e desliga-a num `finally` **antes** de (a) marcar a entrada como `rolled_back` e (b) gravar a entrada compensatória. **Consequência de design:** qualquer escrita que o próprio `apply_rollback()` provoque (ex.: `rollback_elementor()` chama `EMCP_Tools_Data::save_page_data()`, que internamente também grava no ledger via o Recorder) fica **suprimida** — não gera uma entrada duplicada. Mas a **entrada compensatória do próprio rollback** é gravada DEPOIS do `finally` reactivar `$suppress=false`, logo essa **é** gravada normalmente. O resultado líquido: um `rollback-change` produz exactamente **uma** nova entrada no ledger (`action:'rollback'`), nunca duas nem zero. **Este é o padrão exacto a copiar numa réplica** — uma flag estática global de supressão, ligada só durante a aplicação do efeito colateral do próprio rollback, desligada antes do housekeeping final do próprio rollback. **`get()`** é O(n) linear sobre `all()` — sem índice, aceitável até 500 entradas mas não escalaria numa réplica com um teto de retenção maior sem passar a tabela SQL indexada. ### 4.3 O "conflict guard" — `detect_conflict()` Antes de aplicar um rollback (salvo `force:true`), compara `rb['after_hash']` (gravado no momento da escrita original, §4.4) contra `current_hash($rb)` (recalculado **agora**, no momento do rollback). Se diferentes → `WP_Error('conflict', …)`, obrigando o chamador a decidir explicitamente sobre-escrever com `force:true`. **`current_hash()` só sabe recalcular 5 dos 15 tipos de rollback:** ``` elementor-data → hash_elementor(post_id) file-backup/file-create → hash_file(target_path) option → hash_options(option_keys) OU hash_option(option) [legacy single-key] post-fields → hash_post(post_id) meta-before-image → hash_meta(object, id, meta_keys) default (tudo o resto) → '' → tratado como "não é possível recalcular, não bloquear" ``` **Consequência directa e não-óbvia:** rollbacks dos tipos `db-before-image`, `post-create`, `post-restore`, `attachment-delete`, `user-create`, `user-fields`, `acf-fields`, `redirect-row` **nunca disparam o conflict guard** — avançam sempre como se `force:true` estivesse implícito, porque `record_db()`/`record_post_create()`/etc. nunca gravam um `after_hash` correspondente (confirmado por leitura de `class-change-recorder.php` — nenhuma dessas chamadas `record_*()` inclui `rb['after_hash']=…`). O único freio para esses tipos são as verificações **internas** de cada `rollback_*()` (ex.: `user-create` recusa apagar um utilizador que entretanto ganhou `manage_options`; `post-create` devolve `true` silenciosamente se o post já não existir). **Numa réplica que queira o guard uniforme**, seria preciso estender `hash_*()`+`current_hash()` para cobrir também DB rows (hash das colunas-chave), criação/eliminação de posts/utilizadores/anexos, etc. — hoje é uma protecção parcial, não total, apesar do nome "AI-safe transactions" sugerir cobertura completa. ### 4.4 `EMCP_Tools_Change_Recorder` — a fachada de gravação ("o que gravar, quando") `includes/class-change-recorder.php`. **Contrato universal:** cada write-site (uma ability de escrita, fora do âmbito destes ficheiros) é responsável por **capturar o estado "antes" ele próprio, antes de efectuar a mutação**, e passar esse "antes" a um dos métodos `record_*()` do Recorder. O Recorder **não lê o estado anterior por iniciativa própria** — excepto os dois helpers de snapshot explícitos (`snapshot_attachment()`/`snapshot_post()`), documentados com o aviso literal *"MUST be called BEFORE wp_delete_attachment"* / implícito para `wp_delete_post` — ou seja, o padrão é sempre: **1) o chamador lê/captura o antes (directamente ou via `snapshot_*()`), 2) o chamador efectua a mutação, 3) o chamador chama `record_*()` com o antes capturado.** **Os 13 métodos `record_*()` e o que cada um espera como "antes":** | Método | Domain/action gravado | "Antes" esperado do chamador | Estampa `after_hash`? | |---|---|---|---| | `record_elementor(post_id, before_tree, summary, target)` | `elementor` / `page-edit` | árvore `_elementor_data` anterior completa | sim, `hash_elementor()` | | `record_db(entry)` | livre (o chamador constrói a entrada inteira; só `rollback.before_rows` é tratado especialmente) | `entry['rollback']['before_rows']` (linhas SQL antes) | **não** | | `record_file(entry, written_abs)` | livre (chamador constrói) | ref `file-backup`/`file-create` já montado pelo chamador | sim, `hash_file($written_abs)` | | `record_post_fields(post_id, before, summary, target, domain='content', action='update-post')` | configurável | `{fields, meta, terms}` parcial (só o que a escrita pode mudar) | sim, `hash_post()` | | `record_post_create(post_id, summary, target)` | `content` / `create-post` | nada (undo = apagar o post criado) | não | | `record_post_delete(post_id, snapshot, forced, summary, target)` | `content` / `delete-post` | `snapshot_post()` **só se `$forced`** (trash usa `mode:'untrash'`, sem snapshot) | não | | `record_options(before_map, summary, target, domain='settings', action='update-settings')` | configurável | `{option => valor anterior \| '__ABSENT__'}` | sim, `hash_options()` | | `record_redirect(action, before, summary, target)` | `redirect` / create\|update\|delete | `{id}` (create) ou `{row:{...}}` (update/delete) — delega ao applier do Store (§1.3) | não | | `record_meta(object, id, before_map, summary, target, domain='content', action='update')` | configurável | `{meta_key => valor anterior}` (post OU term) | sim, `hash_meta()` | | `record_user_create(user_id, summary, target)` | `users` / `create-user` | nada (undo = apagar utilizador) | não | | `record_user_fields(user_id, before, summary, target)` | `users` / `update-user` | `{campo wp_update_user => valor anterior}` | não | | `record_acf_fields(acf_target, before, summary, target)` | `acf` / `update-fields` | `{field_key => valor bruto anterior}` | não | | `record_attachment_delete(snapshot, att_id, summary, target)` | `media` / `delete-media` | `snapshot_attachment($att_id)` (chamado **antes** de `wp_delete_attachment`) | não | **`attach_before($rb, $heavy)`** — decide inline-vs-blob por **tamanho do JSON codificado**: se `strlen(wp_json_encode($heavy)) > BLOB_THRESHOLD` (4096 bytes) **e** a classe de blobs existe, offload para `EMCP_Tools_Change_Blobs::put($heavy)` e o `rb` guarda só `blob_id`; senão, faz `array_merge($rb, $heavy)` inline. Chamado por `record_elementor`, `record_db`, `record_post_fields`, `record_post_delete` (modo `forced`), `record_options`, `record_meta`, `record_user_fields`, `record_acf_fields`, `record_attachment_delete` — ou seja, **quase todos** os tipos que carregam um "antes" estruturado; os que não têm "antes" (criações) não precisam deste passo. **A flag `partial`** (mencionada nos outputs de `rollback-change`) **não é computada pelo Recorder nem pelo Change_Log** — é um campo que o **chamador de `record_db()`** deve definir ele próprio dentro do `rollback` que constrói, quando limita quantas `before_rows` capturou (ex.: um `update-rows`/`delete-rows` que tope a captura a N linhas por segurança de memória). O Recorder e o Log limitam-se a propagá-lo verbatim até ao output de `rollback-change`. **Contrato implícito para qualquer nova write-tool numa réplica:** se limitares as linhas "antes" capturadas, marca `rollback.partial=true` tu próprio. ### 4.5 O dispatcher de rollback — `apply_rollback()`, 15 tipos `EMCP_Tools_Change_Log::apply_rollback($rb)`. **Primeiro**, resolve um `blob_id` se existir (`EMCP_Tools_Change_Blobs::get()`, funde no `$rb` — devolve `WP_Error('blob_missing')` se o blob já não existir, ex. por ter sido varrido por `prune_before()`), **depois** despacha por `$rb['type']`: | `type` | Applier | Lógica de reversão | Onde vive | |---|---|---|---| | `elementor-data` | `rollback_elementor()` | Regrava a árvore anterior via `EMCP_Tools_Data::save_page_data()` | Change_Log | | `file-backup` | `rollback_file_restore()` | `copy(backup, target)`, confinado a ABSPATH via `EMCP_Tools_Filesystem_Guard::resolve_path()` (doc 07) | Change_Log | | `file-create` | `rollback_file_delete()` | `unlink()` do ficheiro criado, confinado a ABSPATH; já-ausente devolve `true` silenciosamente | Change_Log | | `db-before-image` | `rollback_db()` | `update`: **recusa se `key_cols` vazio** (evita `$wpdb->update()` sem WHERE, que tocaria todas as linhas — regra de segurança dura, não contornável mesmo com `force`); `delete`: reinsere cada `before_rows`; `insert`: apaga por `inserted_key` | Change_Log | | `meta-before-image` | `rollback_meta()` | post OU term meta; valor vazio (`''`/`[]`/`null`) → apaga a chave, senão actualiza | Change_Log | | `post-fields` | `rollback_post_fields()` | Restaura `fields` (wp_update_post), `meta` (sentinela `'__DELETE__'` apaga a chave), `terms` (`wp_set_object_terms`, `append=false`) | Change_Log | | `post-create` | `rollback_post_create()` | `wp_delete_post($id, true)` — force delete; já-ausente devolve `true` | Change_Log | | `post-restore` | `rollback_post_restore()` | `mode:'untrash'` → `wp_untrash_post()`; `mode:'reinsert'` → reinsere do snapshot completo (post+meta+terms), **reaproveita o `id` original se estiver livre** (`import_id`) | Change_Log | | `option` | `rollback_option()` | Por nome: `'__ABSENT__'` → `delete_option()`, senão `update_option()` | Change_Log | | `attachment-delete` | `rollback_attachment_delete()` | Reinsere o post do anexo, restaura toda a meta (`add_post_meta` por valor, não substitui), copia os ficheiros da "lixeira" (`emcp-originals/trash/{att_id}/`) de volta aos caminhos originais | Change_Log | | `user-create` | `rollback_user_create()` | `wp_delete_user()` — **recusa se o utilizador entretanto ganhou `manage_options`** (`rollback_refused`, não `rollback_failed` — código de erro distinto para "recusado por segurança" vs "falhou tecnicamente") | Change_Log | | `user-fields` | `rollback_user_fields()` | `wp_update_user(['ID'=>id, ...before])` | Change_Log | | `acf-fields` | `rollback_acf_fields()` | `update_field($field_key, $value, $target)` por campo — reversão correcta de campos simples E complexos porque passa pela API do ACF, não escreve postmeta bruto | Change_Log | | `redirect-row` | delega a `EMCP_Tools_Redirect_Store::rollback($rb)` | Ver §1.3 | **Redirect_Store** (não Change_Log!) | | *(default)* | — | `WP_Error('unknown_rollback')` | Change_Log | **Ponto de arquitectura chave para a réplica:** 14 dos 15 appliers vivem centralizados em `Change_Log`, mas o `redirect-row` delega para a classe de domínio (`Redirect_Store`). É a **única** excepção — mostra que o dispatcher central é desenhado para permitir extensão por delegação: um novo domínio (numa réplica, ex. um domínio "SEO" ou "menu") pode manter o seu próprio applier de rollback junto do resto da sua lógica de domínio, e o `apply_rollback()` central só precisa de um `case` de uma linha a delegar, sem ter de concentrar toda a lógica num único ficheiro gigante. **Recomenda-se replicar este padrão de delegação por omissão**, não a centralização usada nos outros 14 casos (que provavelmente só não foram refactorizados por serem código mais antigo, escritos antes do padrão de delegação ter emergido). --- ## 5. Content Mirror (export/restore git-friendly) **Classe de abilities:** `EMCP_Tools_Content_Mirror_Abilities` (`includes/abilities/class-content-mirror-abilities.php`) — sempre registada (linhas 191-194 do registrar: `// Content mirror — export/restore page content as git-trackable files (always-on).`). `permission_callback` = `check_permission()` → `edit_posts` (mais permissivo que o ledger, alinhado com Search/Snapshot). **Relação com o ledger** (do header do ficheiro `class-content-mirror.php`): *"Complements AI-safe transactions: transactions are an in-DB recent-change ledger + rollback; the mirror is durable, diffable, file-based history."* — são **mecanismos paralelos e independentes**, não um substituto do outro: o ledger cobre "a última hora de escritas, reversível ao nível da linha"; o mirror cobre "snapshot completo e legível em qualquer altura, feito para diff em git". **O plugin nunca corre `git` a si próprio** — só escreve ficheiros; cabe ao utilizador (ou a um CI) fazer `git add`/`commit`. ### 5.1 Tools | Tool | Input schema (resumo) | O que faz | |---|---|---| | `export-content` | `post_id?:int` (omitido = exporta todos) | Exporta um post/template Elementor, ou todos, para JSON em disco. | | `restore-content` | `post_id*:int`. `required:[post_id]` | Regrava o `_elementor_data` do post a partir do ficheiro mirror existente (undo baseado em ficheiro). | | `list-content-exports` | `{}` (sem propriedades) | Lista os ficheiros de mirror em disco: `{file, id, type, title, exported_at}`. | ### 5.2 `EMCP_Tools_Content_Mirror` — storage em disco `includes/class-content-mirror.php`. **Sem tabela SQL nem custom post type** — armazenamento puro em ficheiro, sob `wp-content/uploads/emcp-content-mirror/` (`MIRROR_DIR` + `wp_upload_dir()['basedir']`). Nome de ficheiro determinístico e legível: `{type}-{id}-{slug-sanitizado}.json` (`type` = `template` se `post_type===elementor_library`, senão `page`; slug passado por `preg_replace('/[^A-Za-z0-9]+/','-', …)` + trim de hífens). `build_export()` (função pura): `{id, type, slug, title, elementor_data, exported_at}`. `export_post()`: obtém `elementor_data` via `EMCP_Tools_Data::get_page_data()` (try/catch — falha silenciosamente para `array()` se a leitura Elementor rebentar), grava com `JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` — **formatação deliberadamente amigável a diff de git**, não a formatação compacta que outras partes do plugin usam para `wp_json_encode()` de opções/BD. No primeiro export de sempre, cria também um `README.txt` explicativo no directório, e o comentário no código é explícito: *"Do NOT ignore json — this dir is meant to be committed."* `restore_post()`: lê o JSON, valida `elementor_data` presente e é array, chama `EMCP_Tools_Data::save_page_data()` — a mesma chamada usada por `rollback_elementor()` no ledger (§4.5). **Consequência prática:** um `restore-content` gera, ele próprio, uma nova entrada no ledger (via o Recorder chamado dentro de `save_page_data()`, fora do âmbito destes ficheiros mas implícito pela partilha do mesmo método de escrita) — os dois subsistemas interoperam: pode fazer-se `restore-content` e depois, se o resultado não agradar, `rollback-change` a esse mesmo restore através do ledger. `export_all()`: varre **só posts `publish`** (`page`/`post` com `_elementor_edit_mode=builder`) + **só templates `publish`** — mais restritivo que `export_post()` (que não impõe status quando o `post_id` é explícito) e mais restritivo que o `rebuild()` do Search Index (§2.2, que indexa `draft/pending/private/future` também). Três políticas de "que status contam" diferentes dentro do mesmo plugin, cada uma justificável pelo seu propósito (indexar rascunhos ajuda a pesquisa; espelhar só o publicado evita ruído no histórico git de conteúdo ainda não decidido) — mas vale a pena decidir isto **conscientemente** numa réplica, não por acidente de cópia de código. **Auto-export opt-in:** `init()` liga `save_post:40` + `before_delete_post:10`, mas `on_save_post()`/`on_delete_post()` só actuam quando `enabled()` → `get_option('emcp_tools_content_mirror_enabled')==='1'` — **desligado por omissão** (a UI de admin tem o toggle em "EMCP Tools → Tools", fora do âmbito destes ficheiros). O `on_delete_post` corre em `before_delete_post` (não `deleted_post`) porque só precisa do `post_type`/`post_name` para calcular o nome do ficheiro a apagar — não precisa que o post já tenha desaparecido da BD. --- ## 6. `EMCP_Tools_Url_Guard` — serviço SSRF partilhado (fora do agrupamento temático) `includes/class-url-guard.php`. Não pertence a nenhum dos 4 subsistemas acima — documentado aqui só porque estava na lista de ficheiros desta tarefa. Duas camadas de validação distintas, adicionadas em versões diferentes: **Camada 1 — `is_safe_remote_url()` + `safe_download()`** (desde 1.9.1, usada pelo sideload de imagem/SVG, doc 09): valida esquema http(s), `wp_http_validate_url()` (bloqueia a maioria dos ranges RFC1918/loopback), depois **complementa** com `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE)` sobre o resultado de `gethostbyname()` — o comentário no código é explícito sobre a lacuna que isto tapa: *"`wp_http_validate_url()` rejects most private RFC1918 ranges, loopback, and non-80/443/8080 ports — but NOT the link-local 169.254.0.0/16 range (which includes the cloud-metadata endpoint 169.254.169.254), and it does not cover IPv6 internal addresses."* `safe_download()` adiciona `reject_unsafe_urls:true` + capa `redirection` a no máximo 2 saltos via um filtro `http_request_args` temporário (adicionado e removido à volta da chamada), para que `download_url()` (que por si só **não** valida saltos de redirect) revalide cada hop. **Camada 2 — `validate()` + `ip_is_blocked()`** (desde 3.2.0, para uma tool `web_fetch` de AI Chat, Pro, fora deste build): gate mais estrito, com resolver **injectável** (`?callable $resolver`) — desenhado explicitamente para ser 100% testável sem rede real. Além de bloquear ranges privados/loopback/link-local (IPv4 **e** IPv6, incluindo o caso IPv4-mapped-em-IPv6 `::ffff:127.0.0.1`, desembrulhado via `inet_pton`), bloqueia também **credenciais na URL** (`user:pass@host`) e restringe a **só as portas 80/443** (`ALLOWED_PORTS`). Resolve **A e AAAA** (não só o primeiro A record, ao contrário da Camada 1) e exige que **todos** os IPs resolvidos sejam públicos — comentário explícito: *"a host publishing one public and one internal record must not slip through."* **Falha fechado**: qualquer IP não parseável em `in_any_cidr()` é tratado como bloqueado. **Limitação documentada e não resolvida (TOCTOU):** o comentário do método é directo sobre isto: *"WordPress's HTTP API connects by hostname, so a TOCTOU window remains between this check and the TCP connect (DNS rebinding). Re-validating every redirect hop and using a short timeout narrow it; closing it entirely needs `CURLOPT_RESOLVE` pinning."* — ou seja, o autor sabe que este guard não é 100% hermético contra DNS rebinding sofisticado, e diz exactamente qual seria a correcção completa (`CURLOPT_RESOLVE`), sem a ter implementado. --- ## Blueprint para réplica **Copiar quase 1:1 (o valor está no design, não no código específico):** - **O mecanismo `$suppress` do ledger** (§4.2) — a peça mais elegante de todo este subconjunto. Uma flag estática global, ligada só durante o efeito colateral do próprio rollback, desligada antes de gravar a entrada compensatória. Sem isto, qualquer rollback que reutilize a mesma via de escrita normal (ex. `save_page_data()`) criaria ruído recursivo no ledger. - **O padrão de offload para blob por tamanho** (`attach_before()`, §4.4) — decidir inline-vs-out-of-band por `strlen(json_encode())` comparado a um threshold simples (4 KB) é suficiente e evita over-engineering; não vale a pena um sistema de chunking mais complexo para este caso de uso. - **O padrão de delegação do `redirect-row` applier** (§4.5) — cada domínio novo deve poder manter o seu próprio applier de rollback junto da sua lógica de domínio, com o dispatcher central a delegar por um `case` de uma linha, em vez de forçar tudo para um ficheiro central gigante (como aconteceu com os outros 14 tipos, provavelmente por acumulação histórica mais do que por escolha deliberada). - **A reutilização de `EMCP_Tools_Page_Snapshot`'s helpers puros pelo Search Index** (§2.2) — nunca duplicar o parsing de árvore Elementor entre dois subsistemas que ambos precisam dela; um só "tree walker" alimenta snapshot E indexação. - **O padrão "seam" via `apply_filters()`** repetido em `emcp_tools_page_snapshot_sections`, `emcp_tools_page_snapshot_seo_lite`, `emcp_tools_search_rerank` — free core declara a forma do output e um valor por omissão sensato (`{available:false, pro_gated:true}` ou o resultado léxico simples); um overlay Pro/plugin externo pode substituir sem o core precisar de saber que ele existe. - **A dupla cobertura clássico+atómico em `content_stats()`** (issue #91, §3.2) — qualquer função que percorra árvores Elementor numa réplica com suporte a 4.0 tem de tratar explicitamente os dois formatos de settings (`$$type`-wrapped vs directo), nunca assumir que um cobre o outro. **Simplificar:** - **O ranking léxico (`Search_Ranker`)** pode começar mais simples do que este TF-IDF aproximado — mesmo uma pontuação por contagem de termos com boost de título já cobriria 90% do valor para um MVP; a fórmula IDF tipo-BM25 aqui só compensa em corpora maiores do que uma réplica inicial provavelmente terá. Manter o seam de rerank desde o dia 1, mesmo que a v1 seja trivial. - **A coluna `tokens` morta no Search Index** (§2.2) — não replicar; se se quiser um índice mais eficiente do que retokenizar tudo a cada pesquisa, ir directo para `FULLTEXT` MySQL sobre `content`/`title`, ou uma tabela invertida `(termo, object_type, object_id, tf)` própria — não guardar tokens concatenados numa coluna que ninguém consulta. - **O `conflict guard` parcial** (§4.3, só 5 de 15 tipos suportados) — decidir deliberadamente se vale a pena estender a todos os tipos (mais seguro, mais trabalho) ou manter parcial e documentar claramente ao utilizador que "conflito" só é detectado para certos tipos de escrita. **Deixar de fora / decidir explicitamente antes de copiar:** - **`match_type` e `ignore_query` no Redirect Manager** (§1.1/§1.3) — campos "de intenção futura" nunca lidos pelo matcher real. Ou implementar o comportamento prometido, ou não os incluir no schema até o fazer. - **As três políticas diferentes de "que status conta"** entre `Search_Index::rebuild()` (todos os status), `Content_Mirror::export_all()` (só publish) e o `export_post()` individual (qualquer status) — cada uma faz sentido isolada mas o conjunto não foi desenhado como um todo coerente; numa réplica, escolher conscientemente por que motivo cada subsistema difere. - **A inconsistência de `meta.annotations` declarado** — Redirect Manager declara sempre `meta` com anotações explícitas; Search/Snapshot/Transactions não declaram nada. Uma réplica deve escolher **um** padrão e aplicá-lo a todas as abilities sem excepção (a camada `emcp_tools_register_ability()` já documentada em `00-ARQUITECTURA.md` §5 seria o sítio certo para impor isto por omissão, em vez de confiar em cada classe de abilities lembrar-se de o declarar). - **O enum de `domain` desalinhado com os domínios reais gravados** em `list-changes` (§4.1) — corrigir antes de copiar, é um bug de schema trivial de evitar desde o início. ## Fonte Leitura directa (19-08-2026) de: `includes/abilities/class-redirect-abilities.php`, `includes/redirects/class-redirect-handler.php`, `includes/redirects/class-redirect-store.php`, `includes/class-url-guard.php`, `includes/abilities/class-search-abilities.php`, `includes/class-search-index.php`, `includes/class-search-ranker.php`, `includes/abilities/class-snapshot-abilities.php`, `includes/class-page-snapshot.php`, `includes/abilities/class-transaction-abilities.php`, `includes/class-change-log.php`, `includes/class-change-recorder.php`, `includes/class-change-blobs.php`, `includes/abilities/class-content-mirror-abilities.php`, `includes/class-content-mirror.php`. Gating condition do Redirect Manager confirmada por grep directo a `includes/abilities/class-ability-registrar.php` (linhas 161-195, incluindo os comentários "always-on" para os outros 3 subsistemas). Contexto de arranque/contrato de registo herdado de `00-ARQUITECTURA.md` e de `skill://emcp-tools` (não relidos linha a linha nesta tarefa, usados só como pano de fundo já validado em sessões anteriores).