he4rtdevs.com · worktree arch-analysis · HEAD 7e6a1206 · 2026-07-12

Revisão de Arquitetura — Oportunidades de Aprofundamento

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.

Índice de candidatos

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

Panorama: o grafo real de dependências

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
    
Regras que seguram: moderation nunca importa bot/integrações; nenhum domínio importa apresentação. Por disciplina, não por enforcement.
Regra que quebrou: Identity importa 7 siblings pra cima → 7 ciclos. Sem arch test, ninguém viu.
Fantasmas: onboarding, squads e events têm ADRs ricos e src/ vazio. O CONTEXT-MAP documenta um sistema à frente do código.

C1 · Dois pipelines de moderação divergentes atrás de duas portas de entrada

Strong
moderation/src/Pipeline/SubmitForModeration.php · Cases/Actions/SubmitReport.php:41-48 · Classification/Jobs/{IngestContent, ClassifyContent, RouteDecision}.php (cadeia antiga) · Jobs/{ClassifyAndRoute, ScreenContent}.php + Actions/RouteCaseAction.php (caminho novo)

Problema

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.

Deletion test

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.

Solução

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.

Benefícios

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.

C2 · A política de auto-execução é uma regra de domínio sem lar

Strong
moderation/…/ClassifyAndRoute.php:67 · ScreenContent.php:92 (branch morto) · bot-discord/src/Listeners/AutoExecuteAction.php:29-35

Problema

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.

Deletion test

Não há o que deletar — a política não tem módulo. Esse é exatamente o problema.

Solução

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.

Benefícios

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

allows(Case): bool
determinismo do classifier
+ ação sugerida + autor resolvido
+ futuras regras de confiança
(tudo escondido aqui)

C3 · Resolução de identidade: um conceito, sete implementações (e um bug latente)

Strong
identity/…/FindOrCreateUserByProvider.php:31-50 · ResolveExternalIdentity.php · LinkExternalIdentity.php · CreateAccountByExternalIdentity.php:16-46 · FindExternalIdentity.php:24-34 · moderation/…/SubmitForModeration.php:82-92 · ScreenContent.php:107-117 · bot-discord/…/DiscordModerationAdapter.php:193-201, 267-272

Problema

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

Deletion test

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.

Solução

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

Benefícios

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

FindOrCreateUserByProvider sem Character
CreateAccountByExternalIdentity sem model_type ⚠
ResolveUserContext Character via Initializer
FindExternalIdentity sem model_type ⚠
SubmitForModeration:82 query inline + Discord fixo
DiscordModerationAdapter ×2 query inline

DEPOIS — um seam profundo

resolve(provider, externalId): User
filtro model_type=user · cache 2d
criação c/ sufixo · garante Profile+Character
merge-awareness

7 callers · 1 invariante · 1 suíte de testes

C4 · Identity é uma fundação falsa: 7 ciclos porque a "base" importa pra cima

Strong
identity/src/** (arestas p/ gamification, profile, activity, integration-{discord,twitch,github,devto}) · CONTEXT-MAP.md ("Identity has no upstream dependencies") · todos os composer.json de módulo (require: {})

Problema

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.

Deletion test

O núcleo (User, ExternalIdentity, OAuth) ganha o sustento — 100+ usos. As arestas pra cima são a complexidade que deveria morar nos consumidores.

Solução

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.

Benefícios

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

C5 · As regras arquiteturais são prosa: nada as aplica mecanicamente

Strong
CONTEXT-MAP.md · tests/Pest.php · tests/Unit/ (o NoLooseArrayCastsTest referenciado nas guidelines NÃO existe) · onboarding/squads/events (src/ vazios com ADRs ricos)

Problema

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.

Solução

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.

Benefícios

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

Regra escrita (prosa)
CONTEXT-MAP · ADRs · guidelines
Regra aplicada (CI)
∅ — nenhum arch(), nenhum require declarado
Depois
arch()->expect('He4rt\Identity')->not->toUse(siblings)

Regras que hoje seguram só por disciplina: moderation platform-agnostic ✓ · domínio nunca importa apresentação ✓. A única sem guarda quebrou (Identity).

C6 · "Registrar interação → dar XP → creditar wallet" não tem seam — e o level-up ficou pra trás

Strong
activity/…/TrackActivity.php:53-66 · ApproveInteraction.php:41-56 · economy/…/Credit.php · gamification/…/IncrementExperience.php · character_levelup (tabela sem NENHUMA escrita)

Problema

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.

Deletion test

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.

Solução

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.

Benefícios

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

TrackActivity
ApproveInteraction
(+ GitHub via C7)
AwardReward::handle()
wallet + Credit + XP
+ level-up + auditoria
(1 transação)

C7 · GithubContributionRecorded é emitido pro vazio: PR no GitHub não dá nada

Strong
integration-github/…/RecordContribution.php:39 (emite) · Events/GithubContributionRecorded.php · grep repo-wide: ZERO listeners · + órfãos irmãos: InteractionTracked, InteractionApproved, TwitchEventReceived

Problema

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

Deletion test

Deletar o evento hoje → nada quebra fora do teste do próprio dispatch. Fiação morta.

Solução

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.

Benefícios

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

C8 · As métricas de saúde da moderação vivem (duplicadas) dentro do painel

Strong
panel-admin/…/ModerationDashboardLivewire.php (417 linhas: fpRate:392-415, healthScore:120-133, slaCompliance:297-312, overturn:257) · Widgets/FalsePositiveRateWidget.php:72-95 (MESMA fórmula de novo) · moderation/src/ não tem read-model nenhum

Problema

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.

Deletion test

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.

Solução

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.

Benefícios

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

ModerationDashboardLivewire (Livewire render)
fpRate · healthScore · slaRate · overturn · repeatOffenders
+ FalsePositiveRateWidget re-implementa fpRate

DEPOIS — read-model profundo no domínio, UI como adapter

Moderation\Reporting
fpRate(period) · health(period)
sla(period) · scoreboard(period)
unit-testado no domínio
Livewire · Widgets
exports · alertas
(adapters finos)

C9 · O scaffold do cluster Discord commitou um caminho de escrita sobre dados de sync

Strong
panel-admin/src/Discord/Resources/{DiscordChannels,Guilds,Members,Roles,EventLogs}/ (Create/Edit pages + Forms required) · panel-admin/docs/plans/2026-07-12-refinamento-cluster-discord.md §5 (a correção já especificada)

Problema

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.

Deletion test

Deletar as páginas Create/Edit + Forms → nenhuma capacidade se perde (edições seriam revertidas pelo sync). Pass-through que não deveria existir.

Solução

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.

Benefícios

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

C10 · Cicatrizes do tenancy: 3 read-paths vivos apontando pro nada + ADR que mente

Strong
panel-admin/…/MessagesRelationManager.php:130,198 (tenant.name searchable/sortable) · app/Livewire/ConnectionHub.php:38,202 (model_type='tenant') · app/Console/Commands/CommunityReport.php:59 (DB::table('tenants') — tabela DROPADA) · identity/docs/adr/0001 (ainda descreve merge com sync de tenants)

Problema

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.

Deletion test

Detritos puros: deletar as 3 referências não exige substituto — apontam pro nada.

Solução

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

Benefícios

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

MessagesRelationManager: tenant.name
relação tenant ∅
ConnectionHub: model_type='tenant'
morph extinto ∅
CommunityReport: DB::table('tenants')
tabela dropada 💥
ADR-0001 identity: "sync tenant memberships"
passo que não roda ∅

C11 · O "event lake" é um conceito implementado quatro vezes

Worth exploring
{github,twitch,whatsapp,discord}_event_logs — 4 models, 4 writers, 4 dedups diferentes: firstOrCreate(delivery_id) · insertOrIgnore(message_id) + json_encode manual ⚠ · firstOrCreate(UUIDv5) · Observer Eloquent

Problema

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.

Solução

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.

Benefícios

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

C12 · Módulos órfãos: events morto, community inalcançável, gamification com superfície morta

Strong
events/ (só ServiceProvider + migration que DROPA as próprias tabelas) · community/ (6 Actions sem nenhum caller, 0 testes; só Meeting/MeetingType vivos via Season) · gamification: character_levelup (0 escritas), LevelException (nunca lançada), ManageReputation/ClaimDailyBonus/ClaimCharacterBadge/Badge Actions (0 callers) · ActivitySourceContract (0 implementações)

Problema

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.

Soluçã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.

Benefícios

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)

100% morto

community (27 arquivos)

models
Actions órfãs

gamification (37 arquivos)

Character/XP/wallet
levelup · badges · reputação

community: 0 testes · events: 0 testes · gamification: 3 testes (só matemática pura de XP)

Menções honrosas (fricção real, alavanca menor)

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.

🏆 Recomendação principal

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

  1. 1. C10 — fixes triviais (bugs ativos)
  2. 2. C5 — arch tests (trava o grafo)
  3. 3. C1+C2 — pipeline único + AutoExecutionPolicy
  4. 4. C3 — ResolveUserFromProvider (fix do model_type)
  5. 5. C6+C7 — AwardReward + listener do GitHub
  6. 6. C4 — inversão do Identity (o maior, por último, já protegido pelos arch tests)