he4rtdevs.com · worktree arch-analysis · HEAD 7e6a1206 · 2026-07-12
Varredura de 21 módulos por 6 agentes paralelos (moderação · identidade · domínio legado · apresentação · integrações · kernel). Vocabulário: módulo (interface + implementação), profundidade (comportamento por unidade de interface), seam, adapter, leverage, localidade, e o deletion test. Cada candidato abaixo é um aprofundamento: transformar módulos rasos em profundos, com testabilidade e navegabilidade como alvo.
+ menções honrosas (ProtectionTier stringly-typed, composição de classifiers inline, drift do DevTo OAuth, He4rt\Core fictício, retrospectiva no portal, constante 0.75) ao final.
Fato estrutural nº 1: todo composer.json de módulo declara "require": {} —
as ~40 arestas abaixo existem só porque o autoloader é compartilhado. Nada impede ciclos, e há 7,
todos centrados no Identity (arestas vermelhas = violação da regra "Identity não tem dependências upstream" do CONTEXT-MAP).
graph LR
classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3
classDef bad fill:#2a1215,stroke:#f87171,color:#fecaca
classDef sink fill:#12161f,stroke:#8b5cf6,color:#d5dae3
classDef ghost fill:#12161f,stroke:#475569,color:#64748b,stroke-dasharray:4
PA[panel-admin]:::sink --> MOD[moderation]:::ok
PA --> ITW[int-twitch]:::ok
PA --> IDC[int-discord]:::ok
PAPP[panel-app]:::sink --> PROF[profile]:::ok
PAPP --> ACT[activity]:::ok
POR[portal]:::sink --> IGH[int-github]:::ok
BOT[bot-discord]:::ok --> MOD
BOT --> IDC
BOT --> ACT
MOD --> ID[identity]:::bad
ACT --> ID
ACT --> GAM[gamification]:::ok
ACT --> ECO[economy]:::ok
IDC --> ID
IDC --> ACT
ITW --> ID
IGH --> ID
DEV[int-devto]:::ok --> ID
GAM --> ID
PROF --> ID
ID -.->|violação| GAM
ID -.->|violação| PROF
ID -.->|violação| ACT
ID -.->|violação| IDC
ID -.->|violação| ITW
ID -.->|violação| IGH
ID -.->|violação| DEV
ONB[onboarding — vazio]:::ghost
SQD[squads — vazio]:::ghost
EVT[events — morto]:::ghost
linkStyle 20,21,22,23,24,25,26 stroke:#f87171,stroke-width:2px
src/ vazio. O CONTEXT-MAP documenta um sistema à frente do código.O ADR-0001 da moderação declara SubmitForModeration como ponto de entrada único e rejeita a cadeia de jobs "Alternativa B". Mas a alternativa rejeitada está viva: SubmitReport roda IngestContent → ClassifyContent → RouteDecision sincronamente e inline — inclusive a chamada de IA de 1–3s que o ADR existia pra eliminar. O roteamento existe em duas implementações que já divergiram: RouteDecision descarta casos abaixo do threshold; RouteCaseAction nunca descarta. Mesmo conceito, duas respostas.
Deletar a cadeia antiga → a complexidade reaparece em SubmitReport, que teria de ser reescrito sobre o caminho sancionado. Os 3 jobs não são módulos ganhando o próprio sustento; são uma duplicata mantida viva por um único caller.
Fazer SubmitReport chamar SubmitForModeration com CaseSource::UserReport; deletar os 3 jobs antigos; colapsar todo roteamento em RouteCaseAction, decidindo uma única vez se "dismiss abaixo do threshold" faz parte do roteamento.
Localidade: um só lugar onde bugs de roteamento podem morar. Leverage: um só pipeline pra aprender. Testes: hoje as duas suítes verdes fixam a duplicação no lugar em vez de pegar a divergência; passa a existir uma suíte só, com a convergência assertável.
ANTES — duas cadeias, roteamento 2×
graph TB classDef entry fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe classDef dup fill:#2a1215,stroke:#f87171,color:#fecaca classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3 A[auto_detect]:::entry --> SFM[SubmitForModeration]:::ok SFM --> SC[ScreenContent]:::ok --> CAR[ClassifyAndRoute]:::ok --> RCA[RouteCaseAction
nunca descarta]:::dup B[user_report]:::entry --> SR[SubmitReport
síncrono + IA inline]:::dup SR --> IC[IngestContent]:::dup --> CC[ClassifyContent]:::dup --> RD[RouteDecision
descarta < threshold]:::dup
DEPOIS — porta única, roteamento 1×
graph TB classDef entry fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3 A2[auto_detect]:::entry --> SFM2[SubmitForModeration]:::ok B2[user_report via SubmitReport]:::entry --> SFM2 SFM2 --> SC2[ScreenContent]:::ok --> CAR2[ClassifyAndRoute async]:::ok --> RCA2[RouteCaseAction
única regra de rota]:::ok
⚠ Hoje o código contradiz o ADR-0001 da moderação — este candidato o realiza, não o reabre.
O CONTEXT e o ADR dizem que a moderação é dona da política "quando é seguro punir sem humano?". Na prática, essa política é um boolean copiado — classifier_version === 'rules' && suggested_action && author_id — escrito verbatim em dois jobs (uma cópia comprovadamente morta) e re-derivado parcialmente num listener do bot-discord. A regra mais crítica de segurança do sistema não tem interface: é conhecimento folclórico duplicado através do seam.
Não há o que deletar — a política não tem módulo. Esse é exatamente o problema.
Extrair uma AutoExecutionPolicy (ou método no Case) que responde se um caso pode auto-executar; emitir CaseReadyForEnforcement por um único ponto. O listener do bot passa a confiar no evento em vez de re-derivar elegibilidade.
A regra mais perigosa do sistema (punir sem revisão) vive num único lugar testável. Deleta o branch morto e as re-checagens defensivas. Mudar a política (ex.: permitir IA > 0.98) vira 1 arquivo, não 3 em 2 módulos.
ANTES — 3 cópias, 0 módulos
ClassifyAndRoute:67
if rules && action && author…
ScreenContent:92
(cópia morta)
AutoExecuteAction:29
re-checa platform+author
DEPOIS — módulo profundo, interface de 1 método
"Me dê o User por trás desta conta externa" tem 3 portas de entrada no identity (login OAuth, runtime/ETL, import) e a query ExternalIdentity::where(provider, external_account_id) hand-rolled em mais 4 call sites na moderação e no bot — ignorando o FindExternalIdentity e seu cache de 2 dias que o ADR-0001 usa como mitigação de risco. Cada caminho assume invariantes diferentes: um cria Character inline, outro via CharacterInitializer, o login OAuth não cria Character nenhum. E o pior: CreateAccountByExternalIdentity e FindExternalIdentity não filtram model_type = user — podem resolver uma credencial de infraestrutura como se fosse uma pessoa. O ADR-0001 §4 do identity proíbe exatamente isso.
As actions têm callers reais — não são pass-through. Falham na metade da localidade: o conhecimento "conta → pessoa" não concentra; um fix do bug de model_type precisa ser feito em 4–5 lugares e vai divergir de novo.
Um módulo profundo — ResolveUserFromProvider — dono de toda a resolução: lookup com model_type=user, criação com sufixo, e as garantias pós-criação (Profile + Character). As 3 portas do identity e os 4 call sites externos chamam através dele. O provider vira parâmetro (tirando o IdentityProvider::Discord hard-coded do domínio "platform-agnostic" da moderação).
A regra pessoa-vs-infraestrutura e o invariante "todo User tem Character e Profile" viram assertáveis uma vez. O cache do ADR volta a valer em todo o pipeline. LinkExternalIdentity e CreateAccountByExternalIdentity hoje não têm teste nenhum — o drift é invisível pro CI.
ANTES — interface espalhada em 7 lugares
DEPOIS — um seam profundo
7 callers · 1 invariante · 1 suíte de testes
O contrato documentado faz do Identity a fundação mais profunda — todos dependem dele. Na realidade, Identity importa 7 siblings de volta (ex.: ResolveUserContext cria Character do gamification; IdentityProvider::getClient() conhece as 4 integrações). O grafo não é um DAG, é um nó: carregar Identity transitivamente carrega as integrações; não dá pra raciocinar sobre sua interface isoladamente. E como nenhum composer.json declara nada, o Composer nunca vai reclamar.
O núcleo (User, ExternalIdentity, OAuth) ganha o sustento — 100+ usos. As arestas pra cima são a complexidade que deveria morar nos consumidores.
Inverter as 7 arestas via seam de contrato/evento: Identity expõe interfaces que as integrações implementam e registram (o padrão OAuthClientContract já existe — falta inverter quem conhece quem), e emite eventos (já faz com AccountsMerged). Depois, declarar as arestas reais em cada composer.json com ^1.0.0.
O nó vira DAG; cada inversão é um adapter real (a integração se registra) em vez do Identity hard-codear use He4rt\IntegrationTwitch\…. Habilita o C5 (arch tests) a travar a direção pra sempre.
ANTES — nó com 7 ciclos
graph TB
classDef bad fill:#2a1215,stroke:#f87171,color:#fecaca
classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3
ID[identity]:::bad
GAM[gamification]:::ok --> ID
PROF[profile]:::ok --> ID
INT[int-discord/twitch/github/devto]:::ok --> ID
ID -.-> GAM
ID -.-> PROF
ID -.-> INT
linkStyle 3,4,5 stroke:#f87171,stroke-width:2px
DEPOIS — DAG: adapters registram-se na base
graph TB classDef deep fill:#0d1f18,stroke:#34d399,color:#a7f3d0 classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3 ID2["identity
contratos + eventos
(AccountsMerged, OAuthClientContract…)"]:::deep GAM2[gamification
listener de user-created]:::ok --> ID2 PROF2[profile]:::ok --> ID2 INT2[integrações
registram seus OAuth clients]:::ok --> ID2
135 arquivos de teste nos módulos, zero testes de arquitetura. As regras de dependência do CONTEXT-MAP, a regra da fundação Identity e o ban de casts array soltos são interface documentada sem seam de enforcement — por isso o C4 cresceu invisível. As guidelines do projeto referenciam NoLooseArrayCastsTest.php como o gate mecânico; o arquivo não existe. E o CONTEXT-MAP descreve onboarding/squads/events como contextos vivos quando seus src/ estão vazios.
Testes arch() do Pest que codificam o CONTEXT-MAP como constraint executável: Identity não importa siblings; moderation não importa bot/integrações; apresentação nunca é importada; migrations não referenciam tenants; casts soltos banidos. Marcar módulos vazios como "scaffold" no mapa.
Converte regra documentada em seam real: a violação falha no CI no ponto da mudança (localidade), em vez de acumular como no panorama. É o candidato mais barato do relatório e protege todos os outros.
O gap hoje
Regras que hoje seguram só por disciplina: moderation platform-agnostic ✓ · domínio nunca importa apresentação ✓. A única sem guarda quebrou (Identity).
Os passos de recompensa — achar Character → wallet → Credit → incrementar XP → mutar a interação — estão copiados verbatim em TrackActivity e ApproveInteraction. O invariante "coins e XP andam juntos, atomicamente" é conhecimento que cada caller re-implementa. Pior: o XP é dado com $character->increment('experience') cru, ignorando o próprio IncrementExperience do gamification — existem dois caminhos de XP divergentes, e nenhum toca level-up: a tabela character_levelup não tem uma escrita sequer no repo.
Deletar o bloco inline → reaparece idêntico nos dois callers. Comportamento que ganha o sustento, mas sem lar — a assinatura de um módulo profundo faltando.
Uma action AwardReward(Character, RewardDTO) (dona: gamification) encapsulando wallet + Credit + XP via IncrementExperience + level-up, transacional. Os dois callers viram uma chamada. É também o gancho natural pro C7 (GitHub) e pra decidir o destino da superfície morta do C12.
Atomicidade coins+XP assertável uma vez (hoje nenhum teste cobre). Um lugar decide "XP mudou → subiu de nível?". Nova moeda/hook/auditoria = 1 arquivo.
ANTES — receita de 5 passos, copiada 2×, level-up órfão
TrackActivity
find → wallet → Credit → increment(xp) → save
ApproveInteraction
find → wallet → Credit → increment(xp) → save
character_levelup
0 escritas · LevelException nunca lançada
DEPOIS — um módulo profundo e transacional
O CONTEXT-MAP apresenta o evento como o seam do integration-github com o resto do sistema, e o ADR-0002 do onboarding especifica um listener dele. O lado emissor existe e é testado; o lado consumidor não existe. Uma contribuição no GitHub entra no lake e não gera XP, coins, nem sinal de onboarding. É um seam com zero adapters — junto com outros 3 eventos órfãos, enquanto o tráfego real entre módulos vai por import direto de model (panel-admin → moderation 53×).
Deletar o evento hoje → nada quebra fora do teste do próprio dispatch. Fiação morta.
Um listener no activity (ex.: AwardGithubContribution) mapeando o evento pra um TrackActivityDTO (provider github, externalRef = ref da contribuição) e chamando TrackActivity — que já tem idempotência e o pipeline de recompensa (e que o C6 aprofunda). Direção de dependência permanece legal: activity depende do evento, nunca o inverso.
Toda fonte de contribuição (Discord, dev.to, GitHub) passa pelo mesmo funil de ingestão. O caminho fim-a-fim "PR aprovado → coins na wallet" vira assertável — hoje é impossível testar porque não existe.
graph LR
classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3
classDef bad fill:#2a1215,stroke:#f87171,color:#fecaca
classDef new fill:#0d1f18,stroke:#34d399,color:#a7f3d0,stroke-dasharray:5
WH[webhook GitHub]:::ok --> RC[RecordContribution]:::ok
RC --> EV[GithubContributionRecorded]:::bad
EV -.->|hoje: ninguém escuta| VOID(("∅")):::bad
EV ==>|depois| L[activity:
AwardGithubContribution]:::new
L ==> TA[TrackActivity
idempotente]:::new
TA ==> AR[AwardReward → XP + coins]:::new
O que conta como falso positivo, o que é uma fila saudável, o que é cumprir SLA — são definições de domínio, não formatação. Elas vivem atrás de uma interface Livewire onde teste de domínio não chega, e a fórmula de FP-rate está escrita duas vezes (componente + widget), podendo divergir em silêncio. Os testes existentes só fazem assertSuccessful — nenhum valor é assertado.
Deletar o Livewire/widgets → as definições teriam de ser reconstruídas por qualquer caller futuro (relatório, API, alerta). Regras ganhando o sustento, no módulo errado.
Extrair um read-model He4rt\Moderation\Reporting (FalsePositiveRate, SlaCompliance, ModerationHealth, ModeratorScoreboard) com métodos que recebem período e devolvem valores tipados. Livewire e widgets viram adapters finos. O padrão saudável já existe no próprio painel: as query classes do Marketing são exatamente isso, e são testadas.
Uma regra, um lugar, N consumidores (dashboard, export, alerta). Fórmulas viram unit-testáveis no domínio em vez de alcançáveis só bootando a página.
ANTES — módulo raso: interface UI de 417 linhas com regras dentro
DEPOIS — read-model profundo no domínio, UI como adapter
As entidades Discord no painel são espelho do sync (integration-discord/src/Sync). O scaffold commitado (HEAD atual) registra Create/Edit e Delete/DeleteBulk — um caminho de escrita da apresentação sobre dados que o próximo sync sobrescreve silenciosamente. Contraria o CONTEXT.md do panel-admin ("never writes domain data"). A direção da dependência está certa; a superfície de mutação está errada.
Deletar as páginas Create/Edit + Forms → nenhuma capacidade se perde (edições seriam revertidas pelo sync). Pass-through que não deveria existir.
Aplicar a seção 5 do próprio plano commitado: converter os 5 resources pra read-only (ViewRecord + ViewAction, sem bulk), assertar /create → 404, e a migration de índice pra tabela de event logs (237k linhas). O repo está sentado no estado intermediário que o plano existe pra corrigir.
O painel deixa de ser um segundo caminho de escrita divergente pro estado sincronizado. Interface pequena e honesta: 5 resources read-only.
graph TB classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3 classDef bad fill:#2a1215,stroke:#f87171,color:#fecaca DAPI[Discord API]:::ok -->|sync = dono da verdade| M[(discord_channels
guilds · members · roles)]:::ok P[panel-admin Resources]:::bad -->|Create/Edit/Delete ⚠| M P2[panel-admin read-only
ViewRecord apenas]:::ok -.->|depois: só leitura| M
A remoção do multi-tenancy (commit 0e40e92e) foi ~95% completa em comportamento, mas deixou 3 caminhos de leitura vivos: uma coluna Filament searchable/sortable sobre a relação tenant que o Message não define mais; o ConnectionHub consultando um morph type extinto; e o CommunityReport consultando uma tabela dropada — falha dura em runtime. Além disso, o ADR-0001 do identity ainda ensina um passo de merge ("sync tenant memberships") que o MergeAccountsAction não executa — a interface documentada mente sobre seus invariantes. E OAuthFlowException::tenantNotFound() é um error mode que nunca pode disparar.
Detritos puros: deletar as 3 referências não exige substituto — apontam pro nada.
Remover coluna/queries/factory morto; nota de supersedência no ADR-0001 do identity (resolução = (provider, external_account_id, model_type=user) global, merge sem tenants); arch test barrando novas referências a tenants (entra no C5).
Remove erros latentes de runtime; o ADR volta a descrever o algoritmo real; leitor de migration não é enganado a achar que o sistema ainda é multi-tenant.
Referências vivas → alvo inexistente
Cada integração re-deriva a mesma interface: envelope materializado + jsonb payload + chave de dedup + insert idempotente + "acabou de chegar evento novo". Os 4 mecanismos são incompatíveis (o do Twitch bypassa casts com json_encode manual e re-query — footgun real), e o seam "evento novo" tem 3 formatos: projector direto, event() e Observer. Quem entende um lake não entende os outros; a 5ª integração re-aprende tudo. O próprio time já reconheceu que é o mesmo conceito: o ADR do WhatsApp renomeou a tabela "alinhando com o sibling discord_event_logs" — convergência de nome sem convergência de código.
Um mecanismo compartilhado — RawEventStore::record(dedupKey, envelope, payload): {model, wasNew} — dono de: colunas canônicas, cast jsonb, hook de sanitize, upsert idempotente e sinal uniforme de "era novo". Cada integração fornece só a estratégia de chave e o callback de projeção. Tabelas continuam separadas.
Bugs de idempotência/sanitize consertados 1×, não 4×. Integração nº 5 = chave + callback. Um lugar pra provar idempotência e tolerância a payload venenoso.
⚠ Respeitar os ADRs do WhatsApp (0002/0003): UUIDv5 determinístico, ingest síncrono 2xx-após-commit, derive-later e sanitize são decisões defendidas — o módulo compartilhado tem de ser mecanismo, não política: estratégia de chave e fan-out plugáveis, ou re-quebra o ADR.
graph TB
classDef bad fill:#2a1215,stroke:#f87171,color:#fecaca
classDef deep fill:#0d1f18,stroke:#34d399,color:#a7f3d0
classDef ok fill:#12161f,stroke:#34d399,color:#d5dae3
subgraph antes [ANTES — 4 escritas divergentes]
G[github: firstOrCreate]:::bad
T[twitch: insertOrIgnore + json manual]:::bad
W[whatsapp: firstOrCreate UUIDv5]:::bad
D[discord: Observer]:::bad
end
subgraph depois [DEPOIS — 1 mecanismo, N políticas]
RES["RawEventStore
record(key, envelope, payload)
→ {model, wasNew}"]:::deep
G2[github: key=delivery_id
projeta inline]:::ok --> RES
T2[twitch: key=message_id]:::ok --> RES
W2[whatsapp: key=UUIDv5
sync, sem fan-out]:::ok --> RES
D2[discord: key própria
handler map]:::ok --> RES
end
antes -.-> depois
Deletion test em escala de módulo: events desaparece sem que complexidade reapareça em lugar nenhum (a própria migration dele derruba suas 7 tabelas). Todo o ciclo de vida Meeting/Feedback do community é inalcançável — nada chama as Actions; só os models são lidos como dados pela Season. E o gamification apresenta uma interface rica (níveis, reputação, badges, bônus diário) da qual só o contador de XP e a wallet estão ligados — ~34 arquivos que humanos e agentes de IA precisam ler e descartar a cada navegação.
Remover events; encolher community aos models vivos (ou marcar explicitamente como inerte com CONTEXT.md); pro gamification, decisão de produto por feature: ligar level-up/badges no seam AwardReward do C6, ou deletar a superfície dormante e registrar no roadmap. Não deixar "level-up" como comportamento implícito-mas-ausente atrás de uma escrita viva de XP.
Superfície honesta: o que existe funciona, o que não funciona não existe. Navegabilidade (humana e de IA) melhora imediatamente; 0 testes perdidos porque não há nenhum.
Massa de código vs. massa viva
events (7 arquivos)
community (27 arquivos)
gamification (37 arquivos)
community: 0 testes · events: 0 testes · gamification: 3 testes (só matemática pura de XP)
ProtectionTier atravessa o seam como string mágica Worth exploring
DiscordRoleResolver retorna 'admin'|'mod'|null; o adapter compara com ===. Um typo falha em silêncio como "sem proteção" — no branch de maior risco. Fix: enum ProtectionTier + match exaustivo.
Composição de classifiers montada inline 3× Worth exploring
O seam ContentClassifierContract é real (3 adapters, bem testado), mas AggregateClassifier::make()->addClassifier(OpenAi…) é copiado em 3 jobs e o toggle de config não evita construção. Fix: binding único no container.
DevTo OAuth derivou do contrato Worth exploring
OAuthClientContract é o seam mais saudável do repo (4 adapters reais) — mas o DevTo não lança tokenExchangeFailed, usa Http:: cru em vez de Saloon e outra convenção de callback. Interface inclui error modes; o DevTo viola os invariantes em silêncio.
He4rt\Core: design system real, kernel fictício Worth exploring
O payload Blade/CSS he4rt:: é profundo (249 usos). O namespace PHP He4rt\Core tem 1 arquivo e 0 callers; o CanonicalJson que as guidelines citam não existe. Renomear pra design-system e criar um kernel de verdade quando houver utilitário real.
Portal é dono das regras de participação da comunidade Worth exploring
CommunityRetrospective (337 linhas no portal) define quem é bot, merged vs closed-unmerged, ranking — lendo GithubContribution direto e filtrando em PHP. Segunda superfície de recap re-derivaria tudo. Relocação, não reescrita (é testado).
A constante 0.75 escondida no painel Strong (pontual)
PeriodStats.php:33 multiplica voice joins por 0.75 — uma suposição de domínio sem nome, sem teste, num método privado de query de UI. Dar nome e lar no activity (ou deletar se for chute).
social_links ainda é cast 'array' solto Worth exploring
Dívida que o próprio ADR-0002 do profile declara. O padrão VO (AsWorkPreferences, AsCredentials) já tem 2 adapters — seam real. Migrar + arch test do C5 fecham o ciclo.
Comandos ETL do Discord morando no kernel Speculative
app/Console/Commands/{Fetch,Import,Analyze}Discord*.php pertencem ao integration-discord/bot-discord, não à raiz. Mudança mecânica de localidade.
Atacar primeiro o C1 + C2 juntos: convergir o pipeline de moderação e dar lar à política de auto-execução.
Por quê: é a única fricção que combina divergência de correção ativa (um caminho descarta casos abaixo do threshold, o outro nunca — o mesmo conteúdo tem destinos diferentes dependendo da porta de entrada), risco de segurança (a regra "punir sem humano" copiada em 3 lugares, 1 morto), e decisão já tomada — o ADR-0001 da moderação já escolheu o design final; o trabalho é fazer o código alcançar o ADR, sem nenhuma discussão de design nova. Escopo fechado (1 módulo + 1 listener), suítes de teste existentes dos dois lados servem de rede, e deleta ~3 jobs + 1 arquivo de teste duplicado.
Em paralelo (trivial, sem grilling necessário): C10 — os 3 read-paths do tenancy são bugs ativos com fix de minutos, e o C5 (arch tests) é barato e trava a direção do grafo antes que os aprofundamentos maiores (C3, C4) comecem.
Sequência sugerida