Root cause found for why the real Slack message showed "→ Validar e
executar" for nearly every ACTIONABLE_NOW ticket: logic/message.js's
ACTION_LABELS map had a hardcoded entry for ACTIONABLE_NOW that always
won over the AI's actual next_action field in shortActionLabel(). The
AI's real, specific, already-validated-as-non-generic next_action (e.g.
"Realizar inventário das máquinas") was being silently discarded for
display purposes on every single message. Fixed by removing the
ACTIONABLE_NOW entry -- it now falls through to the existing
next_action-based fallback (already there, just unreachable for that
class before). UNASSIGNED_ACTIONABLE intentionally keeps its fixed
"Atribuir técnico" label -- that IS the correct display for that class.
logic/classification.js: expanded GENERIC_NEXT_ACTION_PHRASES with the
exact phrase that leaked through ("validar e executar") plus the
mission's additional list (validar chamado, prosseguir atendimento,
executar chamado, tratar chamado, analisar chamado). Bumped
AI_CLASSIFIER_VERSION 3->4 since this changes what next_action values
validate as non-generic -- forces reclassification of any cached
candidate that only passed under the older, narrower blacklist.
n8n/nodes/validate-ai-response.node.js: generic-next-action rejections
now get their own distinct fallback_reason ('generic_or_unsupported_
next_action') instead of being lumped into 'invalid_json', matching the
same pattern already used for the technician-vendor conflict.
logic/slack.js + logic/config.js: added FORCE_SEND (default false).
Manual-triggered runs now never send for real even with SEND_ENABLED=
true in production, unless FORCE_SEND is also explicitly set -- protects
against someone clicking "Execute Workflow" in production and
accidentally re-sending today's digest.
Added format-safety regression tests (no literal backslash-n, URL never
bleeds into the next item's id, no markdown-wrapped URLs) -- verified
these were never actually triggered in a real send (the reported
corruption was n8n's raw JSON data-inspector view, not a delivered
Slack message; both manual runs in question had shouldSend:false).
169/169 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After the user added the missing OAuth scopes (channels:join,
chat:write.public) and reinstalled the Slack App, updated the credential
with the refreshed token and retried the real test send: SUCCESS. The
digest message was actually posted to #chamados-suporte (channel
C0BJNBTPFT5) by "Bot Coordenador", confirmed via the real Slack API
response (ok:true, real message ts/channel/bot_profile returned).
Code - Registrar Envio Sucesso correctly recorded status:'success' --
this record is intentionally left in place (not cleared like the earlier
failed-test rows), since it now correctly represents a real send and its
whole purpose is to prevent a duplicate resend of today's digest.
Also found and fixed a real cosmetic bug from that same successful send:
n8n's Slack node appends an "Automated with this n8n workflow" link
footer by default (otherOptions.includeLinkToWorkflow defaults to true)
-- not part of the message spec (minimal digest only). Disabled it.
Added a regression test.
Reverted SEND_ENABLED to false and deactivated pending final user
confirmation before permanent production activation.
159/159 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Switched channelId from name-based lookup to the exact ID the user
pulled directly from Slack's channel details (C0BJNBTPFT5), eliminating
any ambiguity between similarly-named channels. Retested the real send:
still not_in_channel with the exact ID confirmed from Slack's own UI.
Diagnosed further with a temporary conversations.join attempt (same
credential, same channel) -- Slack returned a precise, actionable error:
"Your Slack credential is missing required Oauth Scopes: channels:join".
This confirms the token/credential itself authenticates correctly (not
a wrong-bot or wrong-workspace problem) -- the Slack App's OAuth scopes
are the real blocker: it has enough to attempt chat.postMessage but not
enough to self-join a channel, and per the earlier not_in_channel result,
also isn't a member and chat:write.public isn't granted either. This is
a Slack App admin-console change (api.slack.com/apps -> OAuth &
Permissions -> add channels:join and/or chat:write.public -> reinstall
app), not something fixable from n8n. Reported to the user with the
exact scope names needed.
Reverted SEND_ENABLED to false, workflow deactivated, both temp nodes
removed, failed test rows cleared from coordenador_glpi_envios_digest
per the user's request.
158/158 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two more real test-send attempts (both with the user's standing
authorization), each confirming a bit more of the pipeline and each
correctly caught/recorded/surfaced by the failure branch -- no message
ever actually reached Slack, exactly as the guard is designed to behave
on failure:
1. channel "CHAMADOS SUPORTE" (as literally given) -> channel_not_found
(Slack channel identifiers are always lowercase-hyphenated).
2. channel "chamados-suporte" -> not_in_channel: the channel resolved
correctly this time, but the Bot Coordenador app is not yet a member
of that channel (chat.postMessage requires channel membership without
the chat:write.public scope). This is a Slack-side action for the
user (invite the bot to the channel), not fixable from n8n config.
Updated the channel value to the confirmed-correct slug. Reverted
SEND_ENABLED to false, workflow deactivated, temp webhook removed.
158/158 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The user provided the real, dedicated Slack destination for this
automation (a separate Bot app credential + its channel), correcting my
earlier inference (the shared "Slack account" credential + $vars.
SLACK_CHANNEL_TECNICO, borrowed from an unrelated archived workflow,
which Slack's own API rejected as invalid_arguments in the prior test).
Created a new slackApi credential ("Slack - Bot Coordenador (Chamados
Suporte)", id lNqwBBrtLfkRqw2X) via n8n_manage_credentials -- the token
was created directly through the n8n credentials API and never written
to any file, workflow parameter, or commit; only the credential's id/name
reference (not a secret) is stored in the workflow, same as every other
credential in this project. Slack - Enviar Mensagem now targets channel
"CHAMADOS SUPORTE" by name instead of the unresolved variable.
158/158 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found running the real Slack test send (with the user's explicit
confirmation to try SEND_ENABLED=true once): the Slack node itself fired
correctly and got a real response back from Slack's API ("invalid_arguments"
-- confirms the pipeline mechanics, credential and node config work end
to end up to that point). Separately, once the failure branch tried to
record it, "Data Table - Registrar Envio Digest (Falha)" crashed with
"Cannot convert undefined or null to object" inside n8n's own insert
operation -- both new envio-digest insert nodes had operation:"insert"
and dataTableId but no "columns" mapping, which is a required property
for insert/update/upsert (confirmed via get_node nodes-base.dataTable).
Fixed both nodes with the same columns.mappingMode:"defineBelow" shape
already used by the existing "Data Table - Registrar Shadow Run" node.
Added a regression test scanning every dataTable node with an insert/
update/upsert operation for a columns.mappingMode.
158/158 tests passing.
Separately (not fixed here, needs the user): the actual Slack
"invalid_arguments" error suggests $vars.SLACK_CHANNEL_TECNICO isn't
resolving to a valid channel on this n8n instance -- no tool available
to this agent can list/inspect n8n instance Variables to confirm. Left
SEND_ENABLED=false and the workflow inactive pending the user's input on
the correct channel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found running the real validation execution against DEV: the envios
digest table starts empty (the normal state for every day until the
first successful send), the "get" operation returned 0 rows, and n8n
does not execute downstream nodes when a node outputs 0 items -- so
Code - Guard de Envio Slack, the IF, the real Slack node, and everything
else in the send branch silently never ran. This would have broken the
happy path on every single day in production, not just an edge case.
Fixed with alwaysOutputData: true on that node (forces at least one
output item even with 0 matches; the resulting empty {} item doesn't
false-positive in alreadySentSuccessfully since none of its fields match
a real record's shape). Re-ran the validation execution after the fix:
Code - Guard de Envio Slack now runs correctly (shouldSend: false,
reason: 'send_disabled' with SEND_ENABLED=false as expected), and the
IF node's true branch (Slack - Enviar Mensagem) is confirmed to never
fire while disabled.
Added a regression test asserting alwaysOutputData on this node.
157/157 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
n8n/workflow.template.json:
- Added the 10 new nodes for the real Slack send branch (Data Table -
Buscar Envios Recentes, Code - Guard de Envio Slack, IF - Deve Enviar
Slack?, Slack - Enviar Mensagem, Code - Pular Envio Slack, Code -
Registrar Envio Sucesso/Falha, the two Data Table - Registrar Envio
Digest nodes, Code - Lancar Erro Envio Slack) and wired every
connection -- Code - Montar Digest Coordenacao now fans out to both
the existing shadow-diff branch (unchanged, always runs) and the new
send branch in parallel, so a Slack failure never loses the shadow
audit trail.
- Renamed "Schedule Trigger - Dias uteis 08h (desligado em DEV)" ->
"Schedule Trigger - Dias uteis 08h": it was already wired to Config
and already had the correct cron (0 8 * * 1-5) from earlier in the
project -- this cycle didn't need to build it, just stop calling it
"desligado" now that it's genuinely production-track (activation is
controlled by the workflow's own active flag, not the node's name).
- Added settings.timezone: "America/Sao_Paulo" explicitly (previously
implicit/unset) -- matches the timezone already hardcoded into
buildTopMessage's dateStr computation elsewhere in this codebase, not
a new assumption.
- Config node: AI_MAX_CANDIDATES 30->60, added ENVIRONMENT/SEND_ENABLED.
tools/build-workflow.test.js: 10 new tests (connections integrity after
a node rename -- would have caught a real bug this cycle where renaming
the Schedule Trigger orphaned its connection key; Schedule Trigger wired
+ correct cron/timezone; Manual Trigger still present; real Slack node
exists with the right credential/onError; guard runs before Slack;
per-ticket envio registration only fires after the success Data Table
write; Slack failure always registers before throwing; AI_MAX_CANDIDATES
== CANDIDATE_POOL_SIZE; SEND_ENABLED/ENVIRONMENT present; GLPI SQL/query
scan for INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE; dataTable node
operation values are valid enum members -- caught a real bug, "getRows"
[the MCP tool's action name] isn't a valid node parameter value, it's
"get"). Replaced the narrow normalize.js-specific transitive-dependency
test with a general one that scans every logic/*.js top-level function
and asserts any node calling it also has it inlined -- this caught a
second real instance of the same bug class (slack.js's use of hash.js's
djb2Hash, missing from 3 new node headers) before it reached a live run.
n8n/deployed/coordenador-glpi-v2.n8n.json regenerated (43 nodes).
156/156 tests passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Real bug found by code review while implementing this cycle: "Code -
Reusar Classificacao em Cache" always hardcoded is_fallback: false when
reconstructing a classification from memory, even when the cached
classification was itself AI_FALLBACK. On a cache hit for a previously
fallback-classified ticket, this would have let it skip
applyFallbackPolicy's exclusion entirely (the policy checks
aiClassification.is_fallback first). Fixed to derive is_fallback from
the cached ai_classification value.
- select-top10.node.js now carries `resolucao` (ia | cache | fallback_cap)
through to the scored item, previously dropped -- needed to tell cache
hits apart from real AI calls in observability.
- Code - Comparar Legacy x V2 now computes the full observability summary
(candidate_count, cache_hits/misses, ai_requested/success/errors,
ai_fallback_count and by-reason breakdown, classification_counts,
insufficient_context_count, actionable_real_count,
critical_override_count, top10_count, blocked_count,
coordination_count) and the data-quality review (buildDataQualityReview
over INSUFFICIENT_CONTEXT tickets), both stored in the shadow_runs
`summary` JSON column -- no new Data Table needed.
- Removed the debug webhook entirely (Debug - Ver Painel, Data Table -
Buscar Shadow Runs Recentes debug branch, Code - Montar Painel Debug,
Debug - Responder) and the now-unused debug-panel.node.js driver.
Workflow went from 37 to 33 nodes. Debugging goes through n8n execution
data, the Data Tables, and the shadow diff instead, as requested.
- Fixed glpi_base_url: was still V1's own placeholder
(https://glpi.suaempresa.com.br, never a real value, not report
anonymization) -- confirmed the real domain (https://glpi.gtigrupo.com.br)
from the live, active GLPI x WhatsApp workflow's own GLPI API config in
the same n8n instance. SQL still only ever receives it through
{GLPI_BASE_URL} substitution from the Config node, never hardcoded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ran the deployed workflow for real (temporary webhook trigger, briefly
activated, immediately deactivated and removed after each attempt) against
the real GLPI MySQL database and a real OpenAI credential. Found and fixed:
1. $env access is blocked on this n8n instance (N8N_BLOCK_ENV_ACCESS_IN_NODE).
parseConfig($env) threw "access to env vars denied" on the very first
Code node. Config now comes from the "Config - Coordenador GLPI v2" Set
node instead (same pattern the V1 workflow already used) -- expanded
that node with all 13 tunable parameters, and pointed the 3 affected
node drivers (build-sql-candidatos, decide-needs-ai, select-top10) at
it instead of $env.
2. Data Table "upsert" operation requires both `columns` (values) and a
separate `filters` (match condition) -- I had only set columns with
matchingColumns, which isn't enough; n8n refused to activate the
workflow ("Missing or invalid required parameters: filters"). Added
filters.conditions matching on ticket_id to both memory-writing Data
Table nodes.
3. Real architecture bug: "Data Table - Buscar Memoria" sat inline in the
41-candidate chain (Mesclar Contexto -> Buscar Memoria -> Decidir
Necessidade de IA). A bulk "get all rows" Data Table node run with 41
input items produced 41 identical fetches and its OWN output (the empty
memory table on a first run) replaced the 41 candidates entirely --
"Decidir Necessidade de IA" received 0 items and the whole main branch
silently skipped. Moved the memory fetch to a parallel branch off
Config (fires once) and added a real Merge node ("Merge - Aguardar
Memoria") to guarantee both the candidates and the memory rows have
executed before "Decidir Necessidade de IA" runs -- same synchronization
pattern already used for context and for the shadow comparison.
Also fixed a cosmetic bug found in the real message output: buildTopMessage
produced a quadruple newline between the header and the first ticket block
([header, '', ...blocks].join('\n\n') double-counts the blank line already
inserted by the '\n\n' join). Added a regression test.
Verified with a real execution (id 13599, 24/08/2026 19:25 UTC) against the
live GLPI database: 41 real candidates, 30 sent to OpenAI (capped), 11
fallback-capped, memory table populated with 41 real rows including
genuine WAITING_VENDOR/WAITING_REQUESTER classifications with real
blocker_type/waiting_for fields extracted from real ticket task content,
and a real shadow_runs row comparing Legacy x V2 (4 tickets removed by V2:
2 for INSUFFICIENT_CONTEXT, 1 WAITING_REQUESTER, 1 WAITING_VENDOR; 4 added).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Fixed 3 remaining "return [...].join(...)" false-positive triggers in
logic/message.js (n8n-mcp's static validator flags any inner return-array
pattern as if it were the node's own output shape) by extracting each to
a named variable before joining. All 3 occurrences of the pattern lived
inside buildTopMessage/buildCoordinationDigest, which get fully inlined
into both "Code - Montar Mensagem Curta" and "Code - Montar Digest
Coordenacao" (whichever function is actually called, the whole module is
inlined), so both nodes needed the same fix.
- Deployed [DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)
(id y6JFEnp7Z2IJEUe2) to devn8n.gtigrupo.com.br: 36 nodes, inactive,
n8n_validate_workflow reports valid:true (0 errors, 1 minor deprecation
warning on continueOnFail).
- Ran n8n_autofix_workflow: 6 safe typeVersion upgrades applied and mirrored
back into n8n/workflow.template.json so future rebuilds stay in sync.
- Found and fixed a real safety issue: creating the workflow before the
"MySQL GLPI - Coordenador" credential existed caused n8n to silently
auto-bind the 3 MySQL nodes to the only other mySql-type credential in
the instance (an unrelated Asaas billing credential). Cleared those
credential bindings (both live and in the template) rather than leave
them pointing at the wrong database.
- Created the two Data Tables the pipeline needs: coordenador_glpi_memoria
(17 columns) and coordenador_glpi_shadow_runs (6 columns).
- Documented the pending schema introspection (glpi_tickettasks.state
column name unverified, blocked on the MySQL credential) and an unrelated
discovery (two other Data Tables, dev_glpi_v2_insights_memory and
dev_glpi_v2_run_history, appeared on the DEV instance mid-session from a
source other than this one) in docs/superpowers/notes/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 14 new Code node drivers implementing the pipeline stages: normalize
candidates, build context SQL, merge context + hash, decide AI need,
prepare AI prompt, validate AI response, AI-disabled fallback, reuse
cached classification, compute score, select top 10, prepare memory/
envio records, build message/digest, compare legacy x V2, debug panel.
- Consolidated the two context queries (followups/tasks) into a single
UNION ALL query tagged by tipo, and added a "Merge - Unir Shadow"
synchronization node before comparing legacy x V2: n8n does not
guarantee relative execution order between sibling branches that only
share an early common ancestor, so cross-branch data was only safe to
read by name once a real Merge node forced both to complete first.
- Reordered score calculation before the memory upsert (score is needed
in the persisted row) and turned both Data Table writes into
side-effect branches that fan out from the main path instead of
gating it, so the Data Table node's own output shape never needs to
carry the full pipeline state forward.
- Fixed a duplicate-identifier bug in the build script: score.js's
require('./cooldown') survived inlining and collided with
cooldown.js's own computeCooldown function in the same Code node
scope. build-workflow.js now strips inter-module requires when
inlining, and a new test syntax-checks every generated jsCode string
plus asserts no leftover require() calls.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>