CORTEX · doc

Tracking Service

berço do lead e comportamento

29 regras · v0.10.0 · rascunho · owner: plataforma · 70 KB · tracking-service · sumário em JSON

Neste documento

CORTEX — tracking-service (berço do lead e comportamento)

Versão: 0.10.0 · Data: 2026-10-14 · Status: Rascunho · Owner: Plataforma Caminho canônico: cortex-docs/services/TRACKING_SERVICE.md Documentos irmãos: cortex-docs/ECOSYSTEM.md (mapa e fronteiras) · cortex-docs/services/IDENTITY_SERVICE.md (identidade e PII) · cortex-docs/standards/UNISCALE_ENGINEERING_STANDARDS.md (padrões técnicos)

Este documento promove a regras endereçáveis as decisões do tracking-service, cujo modelo de propriedade já estava fechado no ECOSYSTEM.md §3/§4/§5 mas cujo desenho estava explicitamente adiado em §7 ("Schemas de tracking e identity").

O tracking-service é o berço do lead_id, o dono do comportamento pseudônimo e a borda de ingestão exposta do CORTEX. Ele não sabe quem a pessoa é (isso é do identity-service) e não decide regra de negócio (isso é do institutional-service) — é executor.

Leia junto com o IDENTITY_SERVICE.md. Os dois serviços são codependentes: o tracking cunha o lead_id (navegador), o identity cunha o person_id (pessoa). Cada regra de fronteira aqui tem a recíproca lá.


1. Berço do lead_id: um identificador de navegador, não de pessoa (Obrigatório)

O lead_id é cunhado num lugar só: o tracking-service, na primeira ação monitorada de qualquer origem, inclusive anônima (ECOSYSTEM §3).

  • O lead_id é um UUIDv7 gerado pela função oficial da plataforma (uniscale.columns.uuid-generation) e é o id da tabela tracking.leads.
  • O lead_id identifica um navegador/origem de navegação, não uma pessoa. Quem identifica pessoa é o person_id, cunhado pelo identity-service apenas quando existe PII (ecosystem.services.identity-person-mint). Confundir os dois é o erro que este parágrafo existe para impedir.
  • Nenhum outro serviço cunha lead_id. Serviço que observa uma origem nova publica um fato; quem cunha é o tracking (§4).
  • O lead_id não circula pelo ecossistema como chave de pessoa: fora do tracking e do identity, os serviços falam person_id (§13).

2. Comportamento é dele; PII não (Obrigatório)

Divisão de propriedade com o identity-service, sem sobreposição (ECOSYSTEM §5 — regra anti-redundância):

Fica no tracking Fica no identity
Trilha de navegação, tráfego/gtag, alto volume Nome, email, telefone, documento
Contexto por evento: sessão, ip, geo, device, utm, referrer Matching, person_id e fusão de pessoas
Tudo chaveado por lead_id, pseudônimo "Quem é" e "por onde passou"
  • O tracking não armazena PII em repouso, com a única exceção do inbox de captura (§6), que é cifrado e efêmero por construção.
  • O tracking não responde "quem é essa pessoa". Essa pergunta é síncrona ao identity.
  • O contexto cru de evento não é duplicado no identity.

3. Borda exposta à internet: relaxe consciente (Obrigatório)

O tracking-service é a borda de ingestão exposta — não existe capture-service à parte (ECOSYSTEM §4).

  • Ficam expostos à internet pública: o endpoint de coleta de eventos (beacon do SDK de browser) e o endpoint de recepção de formulário de terceiro.
  • A exposição é um relaxe consciente e delimitado do princípio "dono de dado sensível nunca exposto": vale só porque comportamento é pseudônimo. PII segue para o identity murado (§7), que não fica exposto.
  • A API de leitura do comportamento (§14) é interna, nunca exposta à internet.
  • Forma de infra da frente (ingress, WAF, rate-limit) é do plano de infra, não deste documento.

4. Nascimento fora da web usa o mesmo berço (Obrigatório)

Lead que chega por canal não-web (WhatsApp, importação, connector) nunca passou por LP e não tem cookie. O berço continua sendo um só.

  • O connector de canal publica um fato de contato; o tracking consome e cunha o lead_id normalmente, com birth_source registrando a origem (web, whatsapp, import, …).
  • A chave de matching desse nascimento é derivada do identificador do canal hasheado (ex.: telefone), nunca o valor em claro.
  • O tracking não guarda o identificador em claro: ele entra na fatia de PII e segue para o identity por §6/§7.

O ecossistema opera com LPs com banner de consentimento, LPs sem banner, e com a possibilidade de recusa. O serviço suporta os três sem desenho alternativo.

O cookie first-party cortex_lid (HttpOnly, Secure, SameSite=Lax, TTL 400 dias) carrega apenas o lead_id e é gravado nos três casos — é identificador próprio, pseudônimo, de uso exclusivo do CORTEX no próprio site. O que o consentimento governa não é o cookie, é o uso do dado:

Base (absent ou denied) Consentido (granted)
Cunhagem do lead_id e captura de comportamento sim sim
IP truncado (/24 IPv4, /48 IPv6) completo
Geo até cidade completo
Fingerprint de device proibido proibido
Envio de conversão ou de pseudônimo de correlação a terceiro (marketing-connector → meta/google; analytics de página, §20) proibido permitido
Retenção do cru 6 meses 24 meses (§12)
  • tracking.sessions registra consent_state (granted, denied, absent), consent_source e consent_at. Vocabulário fechado (uniscale.service-internals.state-machine).
  • Todo fato publicado carrega o consent_state. O consumidor decide o que pode fazer sem consultar ninguém; consumidor que ignora está em violação.
  • Fingerprint de device (canvas, fontes, entropia de browser) é proibido nos dois níveis — não há base legal limpa e a doc restringe o nascimento a "sinais permitidos por LGPD" (ECOSYSTEM §5).
  • A validação da base legal é do jurídico/DPO. Esta regra fixa a mecânica, não o parecer.

6. Captura durável: persistir antes de ackar (Obrigatório)

Formaliza o mecanismo do ECOSYSTEM §5, fixando os parâmetros que lá ficaram em aberto. O formulário da LP é de terceiro: o CORTEX não controla o retry dele, então o instante do recebimento é a única chance que existe.

Sequência obrigatória ao receber uma submissão:

  1. Cunha (ou reconhece) o lead_id (§1) e grava o comportamento.
  2. Cifra a fatia de PII e grava em tracking.capture_inbox com status = 'pending'.
  3. Só então responde 200 ao formulário.
  4. Um worker in-process (dentro do próprio serviço — sem pod novo) drena para o identity pelo canal de §7.
  5. Confirmada a entrega: status = 'delivered' e o payload_encrypted é apagado imediatamente, não em lote diário. A linha permanece com lead_id + timestamps como prova de auditoria.

Parâmetros:

  • Backoff exponencial de 1s a 5min, teto de tentativas de 24h.
  • Esgotado o teto: status = 'dlq_retained' — retido para inspeção humana, nunca descartado. "N tentativas e dropa pro log" é proibido: perde o lead.
  • Cifra AES-256-GCM com chave de envelope; rotação de chave registrada.
  • Consequência aceita e forçada pelo formulário de terceiro: PII fica em repouso cifrada por segundos, mitigada por TTL curto e apagamento após confirmação.

7. Entrega de PII ao identity é canal dedicado, não topic (Obrigatório)

O fato que carrega a fatia de PII (tracking.lead.identified) não é publicado no exchange topic do domínio.

  • Vai em exchange direct dedicado (tracking-pii), com uma única queue declarada (identity-ingestion__lead-identified) e nenhum outro bind permitido. DLQ obrigatória (uniscale.service-internals.messaging-naming).
  • Payload cifrado em trânsito e em repouso na fila.
  • O identity continua sendo escrito só por fila (ECOSYSTEM §4) — esta regra não abre escrita direta.

Justificativa do desvio (registrar como exceção por uniscale.governance.exception-process): o exchange topic existe para que o publicador não saiba quem ouve — propriedade correta para fato de negócio, e exatamente o defeito para PII.

  1. Um bind novo em tracking.# dá a quem o criou uma cópia de toda PII da borda, sem uma linha de código alterada, sem code review, sem alerta.
  2. Com topic, "quem tem cópia da PII" é estado de runtime do broker, não artefato revisável — resposta insustentável numa auditoria LGPD.
  3. Contradiz o princípio declarado em ECOSYSTEM §4/§7: o relaxamento de exposição vale só para comportamento pseudônimo; a PII vai para o identity murado.

Com exchange direct, ler PII exige alterar infraestrutura declarada — o que passa por revisão. O acoplamento resultante (o tracking conhece um destino chamado identity) é intencional: são serviços codependentes por natureza.


8. Identificação do tenant e da propriedade é resolvida no servidor (Obrigatório)

  • O institutional-service cadastra a propriedade digital (site, LP, formulário, conteúdo) e a quem ela pertence, e emite uma write_key por propriedade — não por tenant. A config é distribuída como fato versionado (ecosystem.services.institutional-config-distribution); o tracking mantém cópia local — se o institutional cair, a captura continua.
  • A write_key é pública por natureza (está no HTML da página): ela identifica, não autentica.
  • tenant_id e digital_property_id são resolvidos da write_key, no servidor. Valor de tenant ou de propriedade que venha no payload do cliente é ignorado — aceitá-lo permitiria escrever na conta de qualquer tenant/propriedade.
  • O source_ref continua no evento, mas não é o mecanismo de atribuição: é sub-referência dentro da propriedade (qual formulário da página), declarado pelo cliente e, como o tenant, sem valer como identidade. Quem a página é vem da chave; o que dentro dela, do source_ref.
  • allowed_origins (CORS) é a origem daquela propriedade, não a união dos domínios do tenant — e rate-limit no ingress completa a proteção.
  • tenant_id está em toda tabela de negócio desde o dia 1 (uniscale.columns.multi-tenancy).

Por que a chave é por propriedade, não por tenant. (a) Atribuição verificada, não declarada — a tag copiada de uma LP para outra sem trocar o source_ref atribuiria à página errada para sempre, sem nada acusar; a chave resolvida no servidor não mente. (b) Allowlist deixa de ser frouxa — por tenant, ela lista os domínios de todas as LPs, e um script comprometido numa delas postaria como qualquer outra. (c) Raio de revogação contido — chave por tenant significa que revogar uma vazada derruba todas as LPs do tenant de uma vez; com dezenas de LPs em plataformas distintas, revogação atômica não existe e, na prática, ninguém revoga. Não é trabalho novo para o institutional: ele já cadastra a propriedade (ecosystem.services.institutional-tenant-registry) — emitir a chave junto é consequência.


8-bis. Rotação da write_key tem critério de parada (Obrigatório)

Chave pública que nunca é trocada só some quando vaza. Rotação exige duas chaves válidas ao mesmo tempo — não se troca a tag de dezenas de LPs em WordPress, GreatPages e Webflow no mesmo instante.

  • A ingest_key ganha valid_until (nullable). A chave em retirada continua aceita até esse instante declarado — em vez de um is_active que alguém desliga no escuro.
  • Sequência: emitir a nova → publicar nas páginas → só então marcar a antiga com valid_until.
  • A parte difícil não é emitir; é saber quando a velha parou de ser usada. Por isso a regra exige telemetria de uso da chave: sessions.ingest_key_id — na sessão, não no evento (a sessão nasce uma vez por visitante; no evento multiplicaria escrita sem responder mais nada).
  • Não se revoga chave sem consultar essa telemetria. É o que transforma rotação de mecanismo em procedimento: sem a resposta, revogar é aposta — ou se espera "tempo suficiente" e uma LP esquecida cai, ou não se revoga nunca e a rotação não termina.

9. Idempotência da ingestão pela chave do cliente (Obrigatório)

  • O SDK gera um client_event_id (UUID) no navegador, uma vez por evento, e reenvia sempre o mesmo em caso de retry de rede.
  • No banco: índice único ux_events__tenant_id__client_event_id (uniscale.constraints.prefixes). Reentrega bate no índice e é descartada em silêncio, com 200.
  • Sem isso, oscilação de rede duplica page_view e o funil vira ficção; e duplo clique em submit gera dois lead_id para a mesma origem.
  • Alinhado a uniscale.lineage.idempotency: re-entrega não duplica dado.

10. Vocabulário de evento é controlado e extensível sem deploy (Obrigatório)

  • tracking.events.event_type é varchar validado contra a tabela lookup tracking.event_types — nunca enum de Postgres e nunca string livre.
  • Criar um tipo novo é um INSERT, não um ALTER TYPE: sem migração, sem deploy coordenado. String livre foi rejeitada pelo motivo oposto — vira lixo não-consultável em poucos meses.
  • tracking.event_types.tenant_id é nullable, e a coluna é a fronteira de propriedade do catálogo — o critério é quem é dono do significado:
    • tenant_id IS NULL (global) → dono é o tracking, entra por migration, versionado com o código. Um tipo global só existe se o SDK souber emiti-lo (page_view, click, form_submit): é contrato, não config — acrescentar exige emissor e consumidores entenderem, por isso nasce com o código.
    • tenant_id preenchido → dono é o institutional, entra por fato de configuração (ecosystem.services.institutional-config-distribution), réplica local. cortex.track('visita_ao_campus') não exige nada nosso: é vocabulário de negócio, e o dono do negócio é o institutional.
  • A mesma fronteira vale, de forma idêntica, para os três catálogos: event_types (§10), acquisition_param_types (§10-bis) e business_metadata_keys (§21) — todos com tenant_id nullable, global = contrato do tracking, por-tenant = config do institutional. Tratá-los diferente é o começo da confusão, e os três já têm exatamente a mesma forma.
  • Terceira origem do vocabulário: derivada de integração. Além do que entra pela borda de captura (o SDK emite: page_view, click, form_submit) e do vocabulário de negócio de um tenant (config do institutional), há uma categoria própria: o evento que nasce dentro do serviço como consequência de consumir um fato de outro serviço — ninguém de fora o emite, ele não tem contrato de borda e não pode chegar pela ingestão pública. O crm_stage_change (§26) é o primeiro: a mudança de etapa do card no CRM, capturada pelo crm-connector e consumida como fato, vira um marco na trilha do lead. Seu significado é contrato de plataforma (dono é o tracking, como os globais), mas sua via de entrada é o consumo de fato, não a borda. Reconhecer essa origem à parte evita dois erros simétricos: tratá-la como config de tenant (não é — o sentido é da plataforma) e esperá-la na borda pública (nunca chega por lá; recusá-la ali é o correto).
  • Sementes do catálogo global: session_start, page_view, click, form_view, form_submit, doc_read, video_progress.
  • O que não couber em coluna própria vai em metadata jsonb (uniscale.columns.optional) — nunca dado que merece coluna.

10-bis. Parâmetro de aquisição é dinâmico, e o catálogo é datado (Obrigatório)

Os UTMs são coluna porque são conjunto fechado — source/medium/campaign/term/content/id (seis campos: utm_id entra porque é a chave de reconciliação de campanha no Meta/GA4, tão estável e tão padrão quanto utm_campaign). O que chega sem aviso é outra coisa: gclid, fbclid, msclkid, ttclid, ?parceiro=X, ?convenio=Y.

  • Tudo que não é um desses seis UTMs vai para sessions.acquisition_params (jsonb, índice GIN) — inclusive um utm_* fora do conjunto fechado (ex.: utm_source_platform): é parâmetro de aquisição como qualquer outro e não pode cair no vão entre a regra dos UTM ("pula o que começa com utm_") e a regra do resto, nem sumir em silêncio. Parâmetro novo não exige DDL: nem coluna, nem migração, nem deploy coordenado. O que exige DDL é a otimização, e só quando o parâmetro provar que merece: CREATE INDEX ... ((acquisition_params->>'gclid')) é B-tree sobre o valor extraído, com a mesma performance de coluna dedicada, sem mover dado.
  • tracking.acquisition_param_types registra first_seen_at automaticamente, na primeira vez que o parâmetro aparece. Ele existe pela semântica, não pelo armazenamento.
  • O teto é por destino, e estourar não vira 500. Coluna UTM é varchar(255); acquisition_params (jsonb) não tem largura fixa e comporta teto maior (um fbclid real passa de 200 caracteres). Valor acima do limite do seu destino é recusado na borda com 422 nomeando o campo (§22) — nunca deixado estourar no INSERT e virar 500, que o SDK reenfileira para sempre. Um teto único para destinos de larguras diferentes é o próprio defeito.

Por que o catálogo é obrigatório. Consultar em 2028 "sessões sem gclid" mistura duas coisas que não são a mesma: a visita que não veio daquele canal (ausência conhecida) e a visita anterior à captura do parâmetro (desconhecido). Tratar as duas como "não veio" produz a conclusão errada — "o canal não trouxe ninguém em 2026" — quando só não estávamos etiquetando. A consulta correta tem três respostas, e se limita à janela em que a resposta existe.

Isso não é peculiaridade de JSONB: coluna acrescentada em 2027 deixa as linhas de 2026 com NULL, igualmente indistinguível, e banco de documentos idem — schemaless não responde semântica, apenas esconde a pergunta. A resposta é o catálogo datado, qualquer que seja o formato de armazenamento.

Tetos de construção (a borda é pública). POST /collect é endpoint público e não autenticado; um cliente fora do nosso SDK — ou o SDK com o JavaScript alterado, que qualquer pessoa consegue fazer — pode mandar mil parâmetros de dez mil caracteres, que entram no jsonb e viram linhas novas em acquisition_param_types (o cadastro é automático). É a mesma exposição que o §25 trata na fila de descoberta, e a mesma resposta: teto de parâmetros distintos na sessão (o jsonb), teto de nomes distintos por tenant (o catálogo), teto de tamanho de nome e de valor, e vocabulário de caracteres restrito no nome — validados no servidor, não só no SDK (o teto do SDK, MAX_PARAMS/MAX_PARAM_LENGTH, é conveniência, não controle). Ao atingir o teto de nomes distintos, o auto-cadastro para de registrar nomes novos e só conta — o jsonb ainda guarda o que veio na sessão, mas a tabela-catálogo não cresce a partir da internet. Os três limites têm de existir; os valores vivem no template/config, não nesta regra (precedente do §30.7 e do §25), e o serviço registra os limites efetivos no arranque — afrouxar teto não pode ser silencioso.

Click id é dado pessoal, e o controle é o apagamento — não a retenção curta. gclid, fbclid, gbraid, wbraid, msclkid, ttclid são identificadores de clique: a plataforma que os emitiu liga cada um a uma pessoa identificada, então são dado pessoal de baixa sensibilidade (identificável por terceiro, ainda que não por nós — o teste da LGPD é identificável, não secreto). O tratamento é deliberado, e cada linha fecha uma alternativa que foi pesada e descartada:

  • Retidos junto da sessão, para atribuição de campanha — inclusive de longo prazo (LTV): sem o click id, a definição de campanha guardada no banco não reconstrói qual clique foi daquela pessoa.
  • Não cifrados — são valores públicos (viajam na URL, ficam no navegador da própria pessoa); cifrar acquisition_params derrubaria a consulta analítica que é a razão de ele existir, sem proteger contra o que importa.
  • Apagados pelo esquecimento do titular, automaticamente: o identity.person.erased já purga as sessões de todos os lead_id da pessoa (§12), e os click ids vão junto pela cadeia pessoa → lead_id → session. Este é o controle — o direito ao esquecimento os alcança sem regra nova.
  • Sem expiração própria, porque expirar não protegeria contra ameaça realista: o click id só re-identifica com acesso à conta/pixel da plataforma emissora — quem tem esse acesso não precisa do nosso banco, e quem tem só o nosso banco não resolve o id sozinho. Expirar custaria a atribuição e não mitigaria risco.

11. Modelo de dados (Obrigatório)

Banco próprio tracking_db, schema tracking (uniscale.naming.databases). Sem banco vetorial — o tracking não toca vetorização.

Tabela Papel Soft delete
tracking.leads O berço. id = lead_id, matching_key_hash, first_seen_at, last_seen_at, birth_source sim
tracking.sessions Sessão e contexto: utm_*, acquisition_params, referrer, ip_inet, geo, device, consent_state não
tracking.events Comportamento. event_type, occurred_at, url, metadata. Particionada por mês não
tracking.capture_inbox Fatia de PII cifrada em trânsito para o identity (§6) sim
tracking.lead_identities Vínculo lead_id ↔ person_id (§15) não
tracking.lead_identity_journal Diário append-only de vínculos e refusões, para auditoria não
tracking.event_types Catálogo de tipos (§10) sim
tracking.acquisition_param_types Catálogo datado dos parâmetros de aquisição (§10-bis) sim
tracking.business_metadata_keys Catálogo das chaves de negócio do formulário, não-PII (§21) sim
tracking.metadata_key_candidates Fila de descoberta: nomes de chave recusados, para revisão — nunca o valor (§25) não
tracking.ingest_keys Réplica local da config do institutional: write_key, digital_property_id, allowlist de origem, config_version, valid_until (§8, §8-bis) sim
tracking.outbox Outbox transacional dos fatos publicados não
  • Colunas base obrigatórias em toda tabela (uniscale.columns.base); tenant_id em toda tabela de negócio (§8).
  • person_id em tracking.lead_identities é referência externa ao identity-service: sem FK local, com COMMENT ON COLUMN obrigatório declarando a origem (uniscale.constraints.foreign-keys — FK não cruza fronteira de serviço).
  • A dispensa de soft delete em sessions, events, lead_identities, lead_identity_journal, outbox e metadata_key_candidates é decisão explícita exigida por uniscale.columns.soft-delete: são evento técnico de alto volume, registros de linhagem sem valor de auditoria de vínculo, ou fila transitória que expira (a de descoberta, §25). O direito ao esquecimento se cumpre por purga física (§12), não por soft delete.
  • A dispensa de created_by/updated_by nas mesmas tabelas técnicas é decisão explícita, pelo mesmo critério: em events seriam dois UUID constantes por linha numa tabela que cresce em bilhões, apontando sempre para a única conta de serviço do ingestor — custo real, informação zero. Vale para sessions, events, lead_identities, lead_identity_journal e outbox; as de negócio (leads, capture_inbox, event_types, acquisition_param_types, business_metadata_keys, ingest_keys) mantêm autoria integral (uniscale.columns.authorship).
  • Índices únicos em tabela com soft delete são parciais (WHERE deleted_at IS NULL).

12. Retenção, particionamento e purga (Obrigatório)

  • tracking.events é particionada por mês; expurgo por drop de partição, nunca DELETE em massa.
  • Retenção do comportamento cru: 24 meses no nível consentido, 6 meses no nível base (§5).
  • Purga por titular: o tracking consome identity.person.erased (ecosystem.services.identity-erasure) e apaga fisicamente comportamento, sessões, leads e vínculos de todos os lead_id daquela pessoa, registrando a execução. O tracking não decide apagamento — obedece ao fato.
  • Log operacional vai para o Mongo com TTL e sem PII (uniscale.mongo.forbidden-content) — log referencia por id, não copia registro.

13. Fatos publicados, e o tracking não fala com terceiros (Obrigatório)

Exchange tracking, tipo topic; envelope e nomenclatura padrão (uniscale.events.envelope, uniscale.events.naming).

Fato Quando Canal Consumidores
tracking.lead.created 1ª ação monitorada, sempre topic tracking identity (registra o lead anônimo), crm-connector (cria card), cortex-service
tracking.lead.identified chegou fatia de PII direct tracking-pii (§7) só identity
tracking.event.captured marcos de comportamento (§14) topic tracking cortex-service, marketing-connector (respeitando §5)
  • Todo fato carrega o person_id quando ele já é conhecido, e sempre o lead_id. Serviços fora do par tracking/identity usam o person_id; o lead_id é contexto de origem, não chave de pessoa (§1).
  • O tracking nunca chama a API do Bitrix (nem de qualquer terceiro): publica o fato, e o crm-connector cria/move o card aplicando o mapa configurado no institutional. Mesma fronteira de ecosystem.services.ai-wallet-crm-refs.
  • O tracking não decide raia de CRM — aplica-se o princípio de executor (ECOSYSTEM §2).
  • Publicação via outbox transacional: o fato e a escrita local commitam juntos.

14. O core recebe marco e ponteiro; o cru fica na fonte (Obrigatório)

  • Vão ao cortex-service os marcos (lead.created, lead.identified, form_submit, doc_read) e o ponteiro. page_view e click crus não são publicados — inflariam o core sem ganho analítico proporcional.
  • O comportamento cru permanece no tracking_db e é consultado na fonte (ECOSYSTEM §5: "contexto rico é puxado na fonte"; o core consolida para análise, não para servir contexto ao vivo).
  • Consequência obrigatória: o tracking expõe uma API de leitura interna (síncrona, não exposta à internet — §3) que permite drillar comportamento por person_id e por lead_id, com recorte de período. Sem ela o ponteiro no core aponta para o nada e o dashboard não se constrói.

15. Vínculo lead_id ↔ person_id: o tracking grava, não decide (Obrigatório)

Quem cunha person_id e quem decide que dois registros são a mesma pessoa é o identity-service (ecosystem.services.identity-person-mint, ecosystem.services.identity-matching-signals). O tracking cunha lead_id e obedece — nunca decide identidade.

  • O tracking consome identity.person.identified e grava uma linha em tracking.lead_identities (lead_id, person_id, bound_at, bound_session_id). O consumo é do exchange topic identity, pela fila tracking-identity__person-facts (uniscale.service-internals.messaging-naming), com ack manual, DLQ e idempotência pelo índice único de source_event_id no diário.
  • O tracking não reescreve tracking.events. O evento cru é imutável: registra o que foi observado, não o que depois se soube. O vínculo é uma camada separada, resolvida na leitura (§17).

Justificativa da imutabilidade (reescrita foi rejeitada por três motivos):

  1. Os outros não são reescrevíveis. No instante do vínculo, o lead_id já foi ao core, já virou card no Bitrix e já foi enviado como conversão à Meta. Reescrever só o tracking o dessincroniza de todos.
  2. Identificação erra. Com vínculo em tabela à parte, desfazer é remover uma linha; com reescrita, o original não existe mais.
  3. Volume. UPDATE de milhões de linhas atravessando partições, disparado por consumo de fila, é incidente de produção.

Cardinalidade (Obrigatório): o esperado é 1 person_id para N lead_id (a mesma pessoa se identificando no celular, no desktop e no computador do trabalho). O caso inverso — N pessoas num mesmo lead_id, computador compartilhado por uma família — é exceção reconhecida e não modelada como regra: a tabela permite mais de um vínculo para o mesmo lead_id, o comportamento daquele navegador fica impreciso por pessoa, e a operação segue. bound_at e bound_session_id ficam registrados apenas para forense, não são regra de atribuição.

Fusão de pessoas (Obrigatório): quando o identity funde dois person_id, publica identity.person.merged; o tracking reaponta os vínculos do person_id absorvido para o sobrevivente, na mesma transação, e registra no diário append-only. Consequência: o estado final independe da ordem de chegada dos fatos (uniscale.events.ordering — entrega at-least-once e fora de ordem) e reprocessar a fila inteira dá o mesmo resultado. Idempotente por construção, não por deduplicação. identity.person.unmerged é aplicado da mesma forma, em sentido inverso.

O cookie nunca é reescrito. Um navegador é aquele lead_id até o cookie expirar (400 dias, §5) — é isso que permite reconhecer que a pessoa já esteve nas páginas duas semanas atrás. Vínculo de identidade não altera o lead_id.


16. Vínculo e fusão nunca atravessam tenant (Obrigatório)

  • Constraint dura ck_lead_identities__same_tenant: vínculo só entre lead_id e person_id do mesmo tenant_id.
  • Fato de vínculo ou fusão que atravesse tenant não é aplicado: vai para DLQ retida e dispara alerta.
  • Não existe cenário legítimo cross-tenant. Se esta validação falhar, o vazamento é entre clientes — é a falha mais grave possível neste serviço.

17. Leitura resolve a pessoa (Obrigatório)

  • Consulta por person_id devolve o comportamento de todos os lead_id vinculados àquela pessoa — não o de um navegador só. Consultar por qualquer lead_id vinculado e consultar pelo person_id são recortes diferentes e ambos legítimos; o que não pode acontecer é a mesma pergunta dar respostas diferentes.
  • A resolução é encapsulada em view ou na camada de acesso, como o filtro de deleted_at (uniscale.columns.soft-delete).
  • Comportamento de lead_id sem vínculo é consultável por lead_id e invisível por person_id — permanece pseudônimo até que exista pessoa.
  • Comportamento de lead_id marcado shared é igualmente invisível por person_id, mesmo havendo vínculo — ver §18.
  • Escrita: o comportamento continua chegando com o lead_id do cookie e é gravado como observado. O person_id é resolvido no momento de publicar o fato (§13), não no de gravar.

15-bis. (Substituída) Fusão de leads: apelido, nunca reescrita de histórico

Regra da v0.1.0, substituída na v0.2.0 por ecosystem.services.tracking-lead-identities.

Ela descrevia a fusão como escolha de um lead_id canônico entre vários, com o tracking mantendo tracking.lead_aliases. Esse modelo foi abandonado quando o person_id passou a ser cunhado pelo identity na identificação (ecosystem.services.identity-person-mint): não existe lead_id vencedor — os lead_id coexistem, todos vinculados ao mesmo person_id, e a fusão acontece entre pessoas, dentro do identity.

Mantida aqui apenas como slug depreciado, conforme doc.slug.stability (slug publicado é imutável e nunca reusado). Não implementar.


18. Navegador compartilhado: o lead admite não saber (Obrigatório)

Três pessoas usando o mesmo computador produzem um lead_id. Enquanto ninguém se identifica, não existe informação no mundo que diga qual das três está navegando — nenhum algoritmo resolve isso, e fingerprint de device é proibido (§5) e não separaria mesmo.

O erro a evitar não é o split: é atribuir. Se o comportamento daquele navegador for herdado por todas as pessoas vinculadas, o ai-sales conduz a conversa do candidato A com o interesse que era da irmã, e a ficha de A passa a exibir o que C navegou — vazamento entre duas pessoas que só dividem um teclado.

  • tracking.leads.sharing_state, vocabulário fechado: single | shared. Nasce single.
  • Transição por detecção, nunca por configuração: ao vincular um segundo person_id distinto ao mesmo lead_id, o lead vira shared. Não volta para single automaticamente.
  • Em shared, a consulta por pessoa não devolve comportamento nenhum daquele navegador. O dado não some: continua consultável por lead_id. O que muda é o serviço passar a responder "não sei de quem foi" em vez de chutar.
  • O vínculo N:N continua sendo gravado (§15). Ele é fato — "estas pessoas usaram este navegador" — e serve para auditoria e fraude. O que fica proibido é a leitura atravessar esse vínculo para herdar comportamento.
  • O fato da identificação (quem se identificou, e quando) não se perde: vive no identity (person_leads.bound_at). O que fica sem dono é a linha crua do evento.

Consequência aceita: em computador compartilhado, a trilha por pessoa acaba. Ela nunca existiu — o que existia era invenção, e invenção que ia para o CRM, para o core e para a IA como se fosse verdade. Decisão errada custa mais que decisão não tomada.


19. Comportamento de página é da ferramenta externa; o da pessoa é nosso (Obrigatório)

A fronteira não é por tipo de dado, é por sujeito: o CORTEX captura o que descreve a pessoa e muda a conversa de venda; a ferramenta externa captura o que descreve a página.

Fica no CORTEX (tracking) Fica na ferramenta externa
page_view, click, doc_read, video_progress, form_submit heatmap, profundidade de scroll, rage click, gravação de sessão, Core Web Vitals
  • Gravação de sessão não entra no CORTEX, e o motivo não é custo. A gravação captura o que a pessoa digita antes de submeter — inclusive o que ela apaga e nunca envia. Isso é PII entrando por um caminho que não é o do §7 (canal dedicado, cifrado, destino identity): atravessa por baixo o desenho inteiro de PII, sem consentimento específico e sem que ninguém tenha decidido coletar aquilo. Usar a ferramenta externa, sim; trazer a gravação para dentro, não.
  • Métrica que descreve a página (Core Web Vitals, tempo de carregamento) não tem sujeito: não se correlaciona com lead nenhum e não precisa de chave (§20).
  • O tracking não deixa de capturar o que já é dele porque a ferramenta externa também enxerga. O vocabulário de evento (§10) segue sendo a fonte de verdade do comportamento de negócio; a ferramenta externa não é consultada para decidir venda.

Consequência aceita: heatmap e gravação vivem fora, e analisar página exige abrir outra ferramenta. Em troca, nenhuma PII entra por caminho não desenhado.


20. Correlação com ferramenta externa: pseudônimo por ferramenta, cunhado no servidor (Obrigatório)

Correlacionar a sessão do CORTEX com a da ferramenta externa exige um dado comum. Esse dado não é o lead_id cru: entregue a terceiro, ele vira correlacionável fora do nosso domínio e cruzável entre fornecedores — dois vazamentos independentes passam a compor uma trilha só.

A chave é um pseudônimo derivado por ferramenta:

pseudo_<ferramenta> = HMAC(pepper_<ferramenta>, lead_id)
  • Um pepper distinto por ferramenta. Estável dentro dela (a trilha lá continua fazendo sentido), inútil fora dela (não cruza com a de outro fornecedor), reversível por nós (o casamento de volta é local).
  • Cunhado no servidor. O pepper nunca vai para o browser: tag que calcula o HMAC no cliente publica o segredo, e o pseudônimo vira decoração. O servidor entrega o valor já pronto à página.
  • person_id nunca vai a terceiro. Ele é o handle ligado a PII (identity); enviá-lo anula a fronteira do §2 e de ecosystem.services.identity-pii-at-rest.
  • Só com consent_state = granted (§5) — envio de identificador a terceiro é exatamente o que o nível base proíbe. Sem consentimento, a ferramenta externa roda sem tag de correlação: continua medindo a página, apenas não sabe de quem é a sessão.
  • Correlacionar, não importar. Guardar o pseudônimo e montar link para a fonte ("ver gravação desta sessão"), em vez de trazer o dado para dentro: zero duplicação de PII e sempre atualizado na origem — mesmo princípio de contexto rico puxado na fonte (ECOSYSTEM §5). Importar apenas agregado que muda decisão (ex.: sinal de engajamento para o ai-sales), nunca o cru.
  • A etiqueta é client-side; puxar de volta é connector. A tag da ferramenta roda no browser e recebe o pseudônimo pronto — nenhum serviço do CORTEX chama o terceiro nesse caminho. Puxar agregado da API dele é trabalho de connector (§13: o tracking não fala com terceiro), nomeado por domínio e não por fornecedor (ecosystem.connectors.domain-naming). Enquanto a integração for só etiqueta e link, não existe connector — não criar um antes de haver o que importar.

Consequência aceita: o pseudônimo é opaco também para nós dentro da ferramenta — lá não há como buscar por pessoa, só chegar pela sessão que já sabemos qual é.


21. Campo de negócio do formulário: catálogo, não string livre (Obrigatório)

Um formulário de LP produz o sinal mais valioso que existe — "curso de interesse" é o que o ai-sales precisa para saber sobre o que conversar. Mas hoje esse campo não tem onde morar: o _pii_slice guarda só identificador (name, email, phone, documento) e descarta o resto — minimização correta, exigida por ecosystem.services.tracking-durable-capture — e a borda joga fora campo não declarado (§22). O dado some com 200 na resposta, sem erro.

Não basta abrir o metadata. events.metadata é jsonb livre e não cifrado, e POST /forms é endpoint público e não autenticado (a write_key roda no browser — pública por construção, §8). Aceitar ali qualquer chave deixaria qualquer formulário de terceiro, ou qualquer um com a write_key, despejar CPF e telefone numa coluna em claro — contornando o capture_inbox cifrado por outra porta, exatamente o controle que o _pii_slice existe para garantir.

A saída segue o padrão que o serviço já usa duas vezes (event_types §10, acquisition_param_types §10-bis): catálogo, nunca string livre.

  • Nasce tracking.business_metadata_keys, mesma forma: tenant_id nullable (NULL = global, preenchido = do tenant, sem poluir os demais), first_seen_at, colunas base.
  • Chave não catalogada é recusada (422, com a chave no corpo), não ignorada. Cadastrar chave não exige deploy — é configuração, distribuída pelo institutional por fato (ecosystem.services.institutional-config-distribution).
  • first_seen_at pelo mesmo motivo do §10-bis: sem ele, "quantos pediram Direito em 2026?" não separa ninguém pediu de não estávamos perguntando — as três respostas de lá valem igual aqui.

A restrição que fecha o buraco de PII fica no cadastro, não no envio. A chave catalogada declara que não é identificador: nome que colida com o vocabulário de PII (cpf, email, telefone, documento, rg, passaporte e variações) é recusado ao catalogar. E o valor tem teto de tamanho — campo de negócio é rótulo, não texto livre; texto livre é onde a PII entra por acidente.

Risco que permanece (aceito, não resolvido): alguém pode catalogar observacao e a pessoa digitar o CPF lá dentro. Nenhuma regra técnica resolve isso — é o mesmo caso da gravação de sessão (§19). Registrado nas Fronteiras como risco conhecido e aceito.


22. A borda recusa o que não entende (Obrigatório)

Descartar em silêncio o campo não reconhecido (extra="ignore" na borda de ingestão) passa a ser proibido. Campo que a borda não conhece devolve 422 nomeando o campo.

O motivo não é purismo de contrato. Um campo de negócio descartado sem aviso custou meia hora de teste sem entender por que o dado não aparecia; em produção é uma campanha inteira coletando um campo que nunca chegou, com 200 em toda resposta e nada no banco. Silêncio na borda é o defeito mais caro que existe — só se descobre no dia em que alguém pede o relatório.

Contrapartida honesta: recusar campo não declarado torna a borda frágil a mudança de cliente — um SDK novo mandando um campo a mais quebra. É aceitável porque a borda é nossa e porque o catálogo do §21 é justamente o que permite acrescentar campo sem tocar no contrato. Esta regra depende do §21: sozinha, sem uma via legítima para campo novo, seria um freio ruim.


23. Preenchimento do formulário: forma, nunca conteúdo (Obrigatório)

Saber até onde a pessoa preencheu um formulário não exige o conteúdo — e gravá-lo reabriria o buraco de PII por outra porta.

Proibido gravar valor de campo de formulário em events.metadata — íntegro ou mascarado, truncado, hasheado ou ofuscado, e qualquer derivação reversível. Mascarar não salva: ma***@exemplo.com + 5544*****1111 + as iniciais do nome, na mesma linha com URL e timestamp, re-identifica — e a LGPD só tira do escopo o dado anonimizado, não o pseudonimizado (Art. 12). Seria a mesma PII que o capture_inbox guarda cifrada e apaga em segundos, só que em claro, num jsonb não cifrado, gravado por endpoint público, em tabela imutável e particionada — nunca purgada. Reforça o §21 por outro ângulo: o §21 recusa nomes de PII no cadastro; esta regra recusa o conteúdo, qualquer que seja o nome da chave.

No lugar, a chave reservada form_fields registra só a forma:

  • state por campo, vocabulário fechado: filled | empty | invalid. O invalid é o mais valioso — diz onde o formulário trava.
  • length_bucket opcional e em faixa, nunca número exato: 1-5 | 6-20 | 20+. Faixa porque tamanho exato de CPF/telefone é sinal de identificação; faixa não é.
  • Nada além disso — nenhum valor, trecho ou hash.

form_fields é chave reservada de forma fixa (como source_ref), não catalogada pelo §21 (o catálogo é para rótulo configurável por tenant; configurável viraria depósito — ser fechada é o que garante que ninguém enfie valor ali). A borda valida a forma e recusa com 422 (§22) nome de campo desconhecido, state fora do vocabulário, ou atributo além desses dois.


24. O que não chegou também precisa de sinal (Obrigatório)

O form_submit só chega quando o envio dá certo; abandono no meio ou falha de POST /forms (rede, validação, aba fechada) não deixam registro — e "o formulário quebrou" fica indistinguível de "ninguém se interessou". Grave nas LPs legadas, onde o SDK é o único instrumento. Para saber do que não chegou, o sinal sai antes do submit — dois tipos novos no catálogo global do §10 (contrato: migration, versionados com o código, o SDK precisa saber emiti-los):

tipo quando carrega
form_progress um campo perde o foco (blur) form_fields do estado atual
form_error o submit falhou form_fields + error_code
form_submit envio aceito form_fields como confirmação
  • error_code é vocabulário fechado: network | validation | rejected | timeout.
  • form_progress é coalescido, não um evento por tecla: dispara no blur e manda o mapa inteiro (não o delta) — o último evento basta, e perder os intermediários não perde informação.
  • form_error usa keepalive para sobreviver ao fechamento da aba; se a rede caiu, fica em localStorage e sai na próxima visita, com o occurred_at original (§9, clamp_occurred_at).
  • client_event_id por evento — reenvio deduplica (§9).
  • Consentimento: os três não carregam PII, então o nível base (absent/denied) os aceita; nenhuma exceção ao §5.
  • Nenhum dos dois vira fato. Como page_view/click (§13), form_progress e form_error não vão ao core — form_progress é ainda mais volumoso. Ficam no tracking_db, lidos na fonte pela API interna do §14; acrescentar consumidor é decisão nova.
  • Retenção: form_progress pode ter retenção mais curta que a do form_submit (valor analítico e recente, não histórico) — o número não é fixado aqui; a tracking mede e propõe depois.

25. Recusa vira fila de revisão, nunca catalogação automática (Obrigatório)

O §21 exige chave catalogada e o §22 recusa (422) a que não estiver — juntas, deixam um problema operacional: ninguém sabe quais chaves existem no parque de dezenas de LPs, e cada campo não catalogado é um 422 que quem publicou a LP não vê. O dado não some na borda, mas some no processo.

Quando a borda recusa uma chave de metadata não catalogada, ela registra o NOME da chave como candidata (contagem, primeira/última vez, URLs onde apareceu):

  • Só o NOME. Nunca o valor — ele é descartado como hoje. É isso que mantém a fila fora do alcance do problema que o §21 impede.
  • A resposta ao chamador não muda: continua 422. Registrar não é aceitar.
  • O operador vê a fila e cataloga o que fizer sentido, pelo caminho do §21 (que aplica o CHECK de vocabulário de PII e o teto de tamanho no cadastro). Mesmo padrão da merge_candidates do identity: o automático recusa, mas não descarta a informação de que recusou.

Catalogação automática é proibida em qualquer forma — inclusive "após N ocorrências", "se o valor parecer inofensivo" ou "modo permissivo por um prazo". O §21 tem dois controles e os dois vivem no cadastro (CHECK de nome-PII + teto de tamanho); uma fila que cataloga sozinha contorna os dois por um caminho que parece inofensivo ("veio do tráfego real, é legítima") — e devolveria ao emissor da LP a decisão que a regra tirou dele. A fila registra; a pessoa cataloga.

Limites de construção (endpoint público alimenta a tabela): teto de chaves distintas por tenant (ao atingir, para de registrar nomes novos e só conta); nome truncado e com vocabulário de caracteres restrito; expiração de candidata não catalogada e sem uso — o que ninguém catalogou em meses era erro de digitação, não campo. Sem isso, a fila vira canal de escrita no banco a partir da internet.

Os três limites têm de existir; os valores vivem no template/config, não nesta regra (mesmo princípio do uniscale.git.coverage-layered §30.7 — número em doc envelhece, e estes só se calibram depois de ver tráfego real). O serviço registra os limites efetivos no arranque, para afrouxar o teto não ser silencioso — ex.: fila de descoberta: teto 200 chaves/tenant, nome até 60 [a-z0-9_], expira em 90 dias (valores iniciais, a medir e ajustar por PATCH).

Operação: a fila naturalmente mora no institutional (que cataloga, §21); enquanto não há tela, é consultável pela API interna (§14) e a catalogação é operação administrativa — mesmo estado da merge_candidates do identity.


26. A volta do card: o CRM é fonte de evento da trilha (Obrigatório)

O crm-connector (CRM_CONNECTOR.md §5/§6) devolve ao CORTEX o que acontece com o card no CRM. O tracking consome essa volta e passa a ter o CRM como fonte de evento, ao lado do gtag — sem nunca chamar a API do CRM (§13). A trilha continua dele: o bitrix é origem de um tipo de evento, não autoridade sobre o histórico.

  • Ao nascer o card, o tracking guarda o vínculo entre o lead_id e a referência externa do card (consumindo crm.card.created, CRM §5). É esse vínculo que resolve a volta — sem ele, um evento que traz só a referência do card não tem como virar história de um lead. O tracking é dono desse mapa porque o card nasceu de um lead dele. Como guardar é decisão do serviço; a regra é que o vínculo exista e seja mantido, e que seja o único caminho de resolução (nunca busca por PII).
  • Ao mover, chega só a referência do card e a etapa resultante crua (consumindo crm.card.moved, CRM §6) — sem sujeito, sem etapa anterior, sem id de evento. O tracking, dono da história, faz o que o connector não pode (ele não tem o lead nem guarda estado): resolve o lead_id pelo vínculo acima, deriva a etapa anterior do último marco de etapa daquele lead, e grava um marco de mudança de etapa (crm_stage_change) na trilha — um event_type da 3ª origem (§10), derivado de integração.
  • A etapa viaja crua; traduzir para o estado canônico é leitura. Converter a etapa do CRM para o significado de negócio é feito na leitura/relatório, contra o mapa do institutional — não viaja no evento nem acopla a trilha ao mapa.
  • Idempotência por estado resultante, não por id de evento (o CRM não dá um): se a etapa resultante é igual à última conhecida daquele lead, é no-op. A mesma regra cobre, de uma vez, a reentrega do fato e as edições do card que não mexeram em etapa (o CRM emite em qualquer edição). O filtro "mudou mesmo?" mora em quem tem a história.
  • Fica só na trilha, por ora. Como page_view/click (§13/§14), o marco de etapa não vai ao core/sales; é consultável na fonte pela API interna (§14). Propagar ao sales é decisão nova, quando o loop de vendas existir.
  • Entrega segue a mensageria da plataforma (uniscale.events.*): at-least-once, consumo idempotente, carta-morta — mesmo princípio do §15. Reprocessar a fila inteira dá o mesmo estado final, pela idempotência por estado resultante.
  • O que não volta, por ora: quem moveu o card (ator) e movimentos que não sejam mudança de etapa ficam fora do v1 (CRM §6) — não se supõe, entra como decisão nova.

Fronteiras a definir

Não inventar. Cada item entra como pacote próprio, validando contra esta fundação.

  • Fusão de cards no Bitrix — consequência de §15 para o crm-connector: dois cards para a mesma pessoa após uma fusão. Desenho no pacote do CRM.
  • Mecanismo de distribuição da config do institutional — write_key, allowlist de origem e mapa de propriedades digitais (§8) dependem do mecanismo de distribuição, que ECOSYSTEM §5 registra como "a definir".
  • Gestão e rotação da chave de cifra do inbox (§6) — depende do plano de infra (KMS/vault), não decidido aqui.
  • Parecer jurídico sobre as bases legais de §5 — a regra fixa a mecânica; a validação da base legal é do DPO.
  • Connector de analytics de página — só existe quando houver agregado a importar (§20). Enquanto for etiqueta + link, não há connector; quando vier, é nomeado por domínio, não por fornecedor.
  • Rotação do pepper de correlação (§20) — rotacionar quebra o histórico dentro da ferramenta externa. A política fica no plano de infra, junto com a gestão de chaves de §6.
  • Chave de negócio de nome genérico (§21) — a restrição de nome bloqueia colisão com o vocabulário de PII, mas não impede catalogar observacao e a pessoa digitar o CPF lá dentro. Risco conhecido e aceito, não resolvido — mesmo caso da gravação de sessão (§19); nenhuma regra técnica o elimina.

Impactos em documentos irmãos

Ao promover este documento, atualizar também:

  • ECOSYSTEM.md §7 — a fronteira "Schemas de tracking e identity" deixa de estar adiada: ambos passam a ter doc dedicado.
  • ECOSYSTEM.md §4 — adicionar ponteiro para cortex-docs/services/TRACKING_SERVICE.md no verbete do tracking-service, como já existe para payment/storage/institutional.
  • ECOSYSTEM.md §3, §4 e §5 — o texto atual chama o lead_id de "handle universal que todos os serviços passam a usar". Com o person_id (identity §1), o lead_id é handle de origem de navegação, confinado ao par tracking/identity; o handle de pessoa é o person_id. Emenda necessária.
  • ECOSYSTEM.md §6 — registrar as ferramentas de comportamento de página (Clarity, PageSpeed) na tabela de plataformas externas, com a fronteira de §19/§20.
  • UNISCALE_ENGINEERING_STANDARDS.md §31.3 — registrar a exceção de §7 deste doc (exchange direct para PII, desvio do padrão topic).
  • CRM_CONNECTOR.md §5/§6 — a recíproca da volta do card passa a estar escrita como regra dona aqui (§26): o impacto que o doc do CRM marcava como "emenda a fazer no pacote" está realizado. Ao promover, o CRM pode marcar o item de impacto em TRACKING como fechado.
  • IDENTITY_SERVICE.md — falta a recíproca paralela: o identity consumir crm.card.created para gravar person ↔ card em external_refs quando person_id está presente (CRM §5). Emenda irmã, ainda a fazer — não bloqueia a trilha do §26 (que resolve por lead_id), mas fecha o par do §5.

CHANGELOG

Versão Data Resumo
0.10.0 2026-10-14 Escreve a recíproca da volta do card (habilita o 1º corte de construção do crm-connector, §6 do CRM). §26 nova (tracking-crm-return): o tracking consome a volta do CRM e passa a tê-lo como fonte de evento, ao lado do gtag, sem nunca chamar a API do CRM — ao nascer o card guarda o vínculo lead_id ↔ referência externa do card (o único caminho de resolução, nunca por PII), e ao mover resolve o lead pelo vínculo, deriva a etapa anterior do último marco daquele lead e grava crm_stage_change na trilha; idempotência por estado resultante (etapa igual à última = no-op, cobre reentrega e edição que não mexeu em etapa); fica só na trilha, lido na fonte (§14); ator e movimentos que não sejam mudança de etapa ficam fora do v1. §10 (emenda): reconhece uma 3ª origem do vocabulário de evento — derivada de integração (nasce do consumo de fato, não da borda de captura nem de config de tenant; significado é contrato de plataforma, via de entrada é o consumo), categoria do crm_stage_change. Escrita no estilo conceito, não mecanismo (a decisão de como persistir/consumir é do repo de código). Impacto recíproco em CRM_CONNECTOR (realizado) e IDENTITY (par do §5 ainda a fazer). MINOR.
0.9.0 2026-10-02 §10-bis, a partir do complemento da sessão do tracking (caso real de campanha paga, 02/10). utm_id vira a 6ª coluna UTM — o conjunto fechado passa a source/medium/campaign/term/content/id, porque utm_id é a chave de reconciliação de campanha no Meta/GA4, tão padrão quanto utm_campaign (antes caía no vão: pulado pela regra dos UTM e pela regra do resto, sumia em silêncio). Fecha o vão do utm_* fora do conjunto: qualquer utm_* que não seja um dos seis é parâmetro de aquisição e vai para acquisition_params, nunca some. Teto é por destino, e estourar vira 422, não 500: coluna UTM varchar(255) e jsonb sem largura fixa têm tetos diferentes; valor acima do limite do seu destino é recusado na borda nomeando o campo (§22), nunca deixado estourar no INSERT (um fbclid real passa de 200 caracteres — teto único cortaria em produção). O valor dos tetos segue no template/config (§25/§30.7), calibrável — a sessão propôs 512 para acquisition_params, que é dela por ser config, não contrato. A retenção do click id já estava decidida na 0.8.0 (dado pessoal, apagado pelo esquecimento via §12, sem cifra nem expiração); o complemento não a reabre. MINOR.
0.8.0 2026-09-30 Fecha a pergunta da sessão do tracking sobre click ids (PERGUNTA_click_ids_retencao_e_teto), no §10-bis. Achado 1 — tetos na borda pública: POST /collect é público e não autenticado, e acquisition_params/acquisition_param_types não tinham teto no servidor (só no SDK, que qualquer um altera) — um cliente forjado enche o jsonb e cria linhas novas no catálogo. Passa a valer a mesma resposta do §25: teto de parâmetros distintos na sessão, teto de nomes distintos por tenant (ao atingir, o auto-cadastro para de registrar nomes novos e só conta), teto de tamanho de nome/valor e vocabulário de caracteres — validados no servidor; os três têm de existir, valores no template/config (§30.7/§25) e registrados no arranque. Achado 2 — click id é dado pessoal, decidido: gclid/fbclid/gbraid/wbraid/msclkid/ttclid são identificadores de clique (identificáveis por terceiro → dado pessoal de baixa sensibilidade), e o tratamento fica registrado: retidos para atribuição (inclusive LTV), não cifrados (são públicos e cifrar mata a análise), apagados pelo esquecimento do titular via a purga de sessões já existente no §12 (cadeia pessoa → lead_id → session) — este é o controle — e sem expiração própria, porque só re-identificam com acesso à conta da plataforma emissora, contra o qual expirar não protege. Sem regra nova além dos tetos; a classificação é nota de decisão. MINOR.
0.7.1 2026-09-28 Esclarece a §25 sem mudar a decisão: os três limites (teto por tenant, vocabulário do nome, expiração) têm de existir, mas os valores vivem no template/config, não na regra (precedente do §30.7 — número em doc envelhece, e estes só se calibram com tráfego real). Acrescenta a exigência de o serviço registrar os limites efetivos no arranque (afrouxar o teto não pode ser silencioso). Valores iniciais informados (200 chaves/tenant, nome ≤60 [a-z0-9_], expiração 90 dias) ficam como exemplo/a medir — o número medido entra por PATCH. PATCH.
0.7.0 2026-09-28 Ciclo de vida do formulário + fila de descoberta de chaves (emendas da sessão do tracking). §23 (tracking-form-completeness): proíbe valor de campo de formulário em events.metadata — íntegro ou mascarado/truncado/hasheado (pseudonimizado continua no escopo LGPD, Art. 12; metadata é jsonb não cifrado, endpoint público, tabela imutável nunca purgada). No lugar, a chave reservada form_fields grava só a forma: state (filled/empty/invalid) + length_bucket em faixa. Reforça o §21 pelo conteúdo (o §21 recusa nomes de PII; §23 recusa o conteúdo). §24 (tracking-form-lifecycle): form_progress (no blur, coalescido) e form_error (keepalive, error_code fechado) entram no catálogo global (§10, contrato); nenhum vira fato (como page_view/click, §13), ficam no tracking_db e são lidos pela API interna (§14); sem PII, nível base aceita (§5); retenção pode ser mais curta que a do form_submit (número medido depois). §25 (tracking-metadata-discovery): a recusa do §22 passa a registrar o NOME da chave como candidata (nunca o valor), virando fila de revisão — catalogação automática proibida em qualquer forma (contornaria os dois controles do §21); com teto por tenant, nome truncado/restrito e expiração; a pessoa cataloga pelo §21. §11: entra tracking.metadata_key_candidates. MINOR.
0.6.0 2026-09-24 Fecha os itens devolvidos pela sessão de identity/docs. §8 (emenda): a write_key passa a ser por propriedade digital, não por tenant — o servidor resolve tenant_id e digital_property_id da chave; o source_ref cai a sub-referência declarada (qual formulário), sem valer como identidade; allowed_origins é da propriedade. Motivos: atribuição verificada (tag copiada entre LPs sem trocar source_ref não erra mais), allowlist deixa de ser frouxa, e raio de revogação contido (chave por tenant derrubaria todas as LPs de uma vez). Casa com ecosystem.services.institutional-tenant-registry. §10 (esclarecimento), estendido aos três catálogos: a coluna tenant_id é a fronteira de propriedade — global (NULL) = contrato do tracking (migration, com o código), por-tenant = config do institutional (fato); vale igual para event_types, acquisition_param_types (§10-bis) e business_metadata_keys (§21). §8-bis (nova, tracking-key-rotation): rotação exige duas chaves válidas (valid_until no lugar de is_active no escuro) e telemetria de uso (sessions.ingest_key_id) — não se revoga sem consultar. §11: ingest_keys ganha digital_property_id e valid_until. MINOR.
0.5.0 2026-09-11 Fecha a lacuna do campo de negócio do formulário (defeito de teste 2026-09-10: curso_de_interesse sumia — descartado pelo _pii_slice por não ser PII, e pelo extra=ignore da borda, com 200 nas duas). Acrescenta §21 (tracking-business-metadata): campo de negócio não-PII vive em events.metadata, mas validado contra o catálogo tracking.business_metadata_keys — mesma forma de event_types/acquisition_param_types (tenant_id nullable, first_seen_at datado, sem deploy, distribuído pelo institutional). Chave não catalogada é recusada (422), não ignorada. O buraco de PII (metadata livre e não cifrado num endpoint público) é fechado no cadastro: nome que colida com vocabulário de identificador (cpf, email, telefone, documento…) é recusado ao catalogar, e o valor tem teto de tamanho (rótulo, não texto livre). Acrescenta §22 (tracking-strict-edge): extra=ignore na borda passa a proibido — campo não reconhecido devolve 422 nomeando o campo; silêncio na borda é o defeito que só se descobre no dia do relatório. §22 depende do §21: o catálogo é o que permite acrescentar campo sem mexer no contrato. §11: entra tracking.business_metadata_keys. Nova fronteira: chave de nome genérico (observacao) ainda admite PII digitada — risco conhecido e aceito, como a gravação de sessão do §19. MINOR.
0.4.0 2026-09-10 Fecha a fronteira entre comportamento de pessoa e comportamento de página. Acrescenta §19 (tracking-page-analytics-boundary): o CORTEX captura o que descreve a pessoa e muda a conversa de venda (page_view, click, doc_read, video_progress, form_submit); heatmap, scroll, rage click, gravação de sessão e Core Web Vitals ficam na ferramenta externa. Gravação de sessão não entra — e o motivo não é custo: ela captura o que a pessoa digita antes de submeter, inclusive o que apaga e nunca envia, o que faz PII entrar por um caminho que não é o canal dedicado do §7, sem consentimento específico e sem decisão de coletar. Acrescenta §20 (tracking-third-party-pseudonym): a chave de correlação é HMAC(pepper_<ferramenta>, lead_id), com pepper distinto por ferramenta e cunhado no servidor (pepper no browser publica o segredo e torna o pseudônimo decorativo); person_id nunca vai a terceiro; envio só com consent_state = granted, e sem consentimento a ferramenta roda sem tag de correlação. Fixa correlacionar, não importar: guardar o pseudônimo e montar link para a fonte, importando apenas agregado que mude decisão. A etiqueta é client-side; puxar da API do terceiro é trabalho de connector (§13), que não se cria antes de haver o que importar. §5: a linha de envio a terceiro passa a cobrir o pseudônimo de correlação, não só a conversão. MINOR.
0.3.0 2026-09-10 Acrescenta §10-bis (tracking-acquisition-params): parâmetro de aquisição fora dos UTMs vive em sessions.acquisition_params (jsonb + GIN) e não exige DDL; a otimização é índice de expressão, só quando o parâmetro virar quente. E o catálogo acquisition_param_types grava first_seen_at automaticamente, porque sem ele uma consulta futura não separa ausência conhecida de desconhecido e conclui que o canal não trouxe ninguém quando apenas não era etiquetado — problema que existiria igual com coluna nova ou banco de documentos. Acrescenta §18 (tracking-lead-sharing): o lead ganha sharing_state (single/shared), detectado ao vincular um segundo person_id distinto. Em shared, consulta por pessoa não devolve comportamento daquele navegador — o dado continua consultável por lead_id, mas o serviço passa a dizer "não sei de quem foi" em vez de herdar o comportamento para todas as pessoas vinculadas. O motivo não é precisão: atribuir faz a ficha de uma pessoa exibir o que outra navegou, o que é vazamento entre duas pessoas que só dividem um teclado, e faz a IA conduzir a conversa com o interesse errado. O vínculo N:N continua gravado como fato (auditoria, fraude); o que se proíbe é a leitura atravessá-lo. §17 passa a citar a regra. MINOR.
0.2.2 2026-09-10 Reconcilia a regra com a implementação, sem mudar decisão de arquitetura. §11: entra tracking.ingest_keys — réplica local da config do institutional, exigida por §8 mas ausente da lista de tabelas; e a dispensa de autoria nas tabelas técnicas passa a ser declarada explicitamente, ao lado da dispensa de soft delete que já estava (em events seriam dois UUID constantes por linha numa tabela de bilhões, apontando sempre para a mesma conta de serviço). §15: nomeia a fila tracking-identity__person-facts por onde os fatos do identity voltam, com ack manual, DLQ e idempotência por source_event_id — o comportamento já era regra, faltava o endereço. PATCH.
0.2.1 2026-09-02 Editorial de governança, sem mudança de regra ativa: restaura o slug ecosystem.services.tracking-merge-alias como depreciado, com superseded-by=ecosystem.services.tracking-lead-identities (§15-bis). Na v0.2.0 a seção foi substituída sem deixar marcador, e o slug — que já estava publicado — passou a responder unknown rule slug, violando doc.slug.stability (slug publicado é imutável e nunca reusado; regra substituída marca o antigo, não o apaga). Nenhuma regra ativa muda. PATCH.
0.2.0 2026-08-31 Ajusta o modelo de identidade à decisão de arquitetura: o person_id é cunhado pelo identity na identificação, e o lead_id passa a ser explicitamente identificador de navegador, não de pessoa (§1). Em consequência: tracking.lead_aliases (lead_id canônico) é substituída por tracking.lead_identities (lead_id ↔ person_id) e lead_merges por lead_identity_journal (§11); §15 é reescrita — o tracking grava o vínculo publicado pelo identity em vez de eleger um lead_id vencedor, com cardinalidade 1 pessoa : N navegadores e a exceção do computador compartilhado reconhecida e não modelada como regra; §17 deixa de reescrever o cookie (o lead_id é estável por 400 dias, que é o que permite reconhecer a visita de semanas atrás); §12 passa a executar purga por consumo de identity.person.erased; §13 fixa que todo fato carrega person_id quando conhecido. Adiciona à seção de impactos a emenda ao ECOSYSTEM §3/§4/§5 sobre "handle universal". MINOR.
0.1.0 2026-08-31 Documento inicial. Promove a @rule endereçável (área services, slugs ecosystem.services.tracking-*) o desenho do tracking-service, que ECOSYSTEM §7 mantinha adiado. Fixa: berço do lead_id em UUIDv7 (§1); divisão de propriedade com o identity (§2); borda exposta como relaxe delimitado (§3); nascimento não-web pelo mesmo berço (§4); consentimento em dois níveis com consent_state viajando no fato, e proibição de fingerprint (§5); parâmetros da captura durável que a doc deixou em aberto — backoff, teto de 24h, apagamento imediato pós-entrega, AES-256-GCM (§6); entrega de PII em exchange direct dedicado, desvio consciente do topic, a registrar como exceção (§7); tenant resolvido da write_key no servidor (§8); idempotência por client_event_id (§9); vocabulário de evento em lookup extensível sem deploy (§10); modelo de dados com dispensa explícita de soft delete por tabela (§11); retenção diferenciada por consentimento e purga por titular (§12); fatos publicados e proibição de falar com terceiros direto (§13); core recebe marco e ponteiro, com API de leitura interna obrigatória (§14); vínculo de identidade sem reescrita de histórico (§15); nunca cross-tenant (§16); leitura resolve a pessoa (§17).