Files
emcp-tools-mapping/docs/05-REDIRECTS-SEARCH-LEDGER.md
Claude Code 8ada367bd0 docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)
Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp,
GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt.

- 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter)
- 01: Elementor classico (paginas, layout, widgets, templates, globals)
- 02: Elementor Atomic v4 + Gutenberg
- 03: WordPress core (conteudo, media, settings, temas)
- 04: Themer (CPT, condicoes, render, PHP templates)
- 05: Redirects + change ledger unificado (rollback)
- 06: Sandbox PHP snippets + custom widgets
- 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco)
- 08: Integracoes terceiros (ACF, Meta Box, forms, SEO)
- 09: Stock images + Cloud + OAuth
- 10: Sistema de modulos + inventario Pro-only (30 classes)
- INDEX: sintese, sequencia de construcao, tabela de risco

Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de
consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
2026-08-19 06:41:04 +01:00

51 KiB

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):

// 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:

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<enum page,template,widget,global_color,global_font,global_class>, 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<mesmo enum> (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.

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 labels 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<enum performance,a11y,seo>, sections?:array<enum post,structure,tokens,responsive,content,seo_lite,warnings>, 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:

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).