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).
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ópathviawp_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)— quandotarget_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(): percorreEMCP_Tools_Widget_Catalog::get()(catálogo PHP estático, doc 02 — não vem de BD nem de posts), constróicontenta partir de title+use_case+keywords+category+widget_type(normalizado)+nomes dos parâmetros. - pages —
WP_Querysobrepage/post, todos os status (publish/draft/pending/private/future), filtro_elementor_edit_mode=builder. - templates —
WP_Querysobreelementor_library, mesmos status. - globals — lê
_elementor_page_settingsdo kit activo (elementor_active_kit), indexasystem_colors+custom_colorscomoglobal_coloresystem_typography+custom_typographycomoglobal_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 porlog(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 finalsum((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 doarray_sliceao 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 viainclude, cacheadas em transient 15 min (emcp_snap_{post_id}_{section}), bypass comfresh: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}comcontainers,widgets,by_widget_type,max_depth,total_elements; cada nó da árvore ganhalabelderivado deelement_label().element_label()— primeiro campo não-vazio de_title|title|text|editor|heading_titlenas settings, tags HTML removidas, cortado a 60 chars (snippet()).extract_tokens()— cores/tipografia globais referenciadas via__globals__(regexglobals/colors?id=…/globals/typography?id=…), classesg-(de_css_classes/classesstring OUclasses.valuearray — dois formatos coexistentes, clássico vs atómico), fontes/cores hex "em uso" (heurística: chave de settings contémfont_familyoucolor+ 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(recursivohas_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 queexport_post()(que não impõe status quando opost_idé explícito) e mais restritivo que orebuild()do Search Index (§2.2, que indexadraft/pending/private/futuretambé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
- 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
$suppressdo 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 porstrlen(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-rowapplier (§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 umcasede 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 ememcp_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
tokensmorta no Search Index (§2.2) — não replicar; se se quiser um índice mais eficiente do que retokenizar tudo a cada pesquisa, ir directo paraFULLTEXTMySQL sobrecontent/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 guardparcial (§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_typeeignore_queryno 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 oexport_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.annotationsdeclarado — Redirect Manager declara sempremetacom 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 camadaemcp_tools_register_ability()já documentada em00-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
domaindesalinhado com os domínios reais gravados emlist-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).