Ir para o conteúdo
mcpd
sem daemon
Código

Ocultação e retenção

O que nunca chega a ser armazenado, e o que deixa de ser armazenado. A ocultação roda antes da gravação, porque um segredo apagado depois já tinha sido escrito.

A ocultação roda antes do armazenamento

Prompts e respostas do modelo são a coisa mais valiosa que o mcpd guarda, e a mais perigosa. A ocultação é o terceiro estágio do pipeline de ingestão, antes da gravação, então um valor que uma regra casa nunca chega ao banco de dados.

Ingest pipeline
  1. Decode

    OTLP or native, gzip or plain

  2. Sample

    Decided once, at the root

  3. Redact

    Before anything is written

  4. Price

    From the configured price book

  5. Store

    One transaction per batch

Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.

A ordem é o ponto inteiro. Um valor limpo depois do fato esteve no disco, no write-ahead log, e muito provavelmente no backup de ontem à noite. Nunca ter sido escrito é uma garantia diferente de ter sido apagado depois, e é a única que você consegue afirmar sem rodeios a quem perguntar.

A ocultação vem ligada por padrão. Uma ferramenta que só protege você depois de configurada não protege ninguém. O --no-redact, ou MCPD_NO_REDACT, desliga isso, o que só faz sentido quando algo antes dele já oculta.

A ocultação é a ponta do fio que fica no daemon. Os SDKs seguram a outra ponta: MCPD_CAPTURE_PAYLOADS=false, ou capture_payloads no código, mantém prompts e respostas dentro do processo. O span ainda chega com o tempo, o status, o uso, o modelo, a correlação e os erros, e input e output são descartados antes de entrarem na fila, então eles nunca atravessam a rede. Um time que não pode mandar esses bytes de jeito nenhum define isso, e um span que não carrega nada sensível pode voltar a participar por conta própria.

As regras padrão

Uma regra é um conjunto de trechos em minúsculas mais um modo. Uma chave casa quando o nome dela, em minúsculas, contém qualquer um deles. Casar por trecho é o que faz X-API-Key, authorization e customer_email serem todos pegos sem que cada grafia e cada prefixo estejam listados.

ModoNomes de chave que casam
mask api_key, apikey, api-key, secret, password, passwd, access_token, refresh_token, id_token, bearer, authorization, auth_token, private_key, client_secret, session_token, credential, signature, x-api-key
partial email, phone, ssn, credit_card, card_number, iban
hash user_id, userid

Quando uma chave casada contém um objeto ou um array, tudo o que está embaixo dela também é ocultado. {"credential": {"value": "hunter2", "note": "prod"}} perde os dois campos, não só aquele cujo nome parecia perigoso.

O limite vale ser dito sem rodeios: as regras casam nomes de chave, não valores. Uma chave de API que alguém colou no meio de um prompt, e ficou armazenada sob uma chave chamada content, não é casada por nada aqui. A ocultação impede a captura estruturada de credenciais e identificadores. Ela não é um varredor de conteúdo, e tratá-la como se fosse vai decepcionar você.

Os quatro modos

ModoO que acontece com o valor
drop A chave é removida por completo, como se nunca tivesse sido enviada.
mask O valor é trocado por [redacted].
partial Os dois primeiros e os dois últimos caracteres sobrevivem, unidos por reticências: alice@example.com vira al…om. O bastante para reconhecer um valor que você já conhece, não o bastante para usar. Um valor com seis caracteres ou menos é mascarado por inteiro, porque manter quatro de cinco caracteres revela quase tudo.
hash O valor é trocado por sha256: mais os doze primeiros bytes de um SHA-256 com chave, como vinte e quatro caracteres hexadecimais.

As regras embutidas usam três dos quatro modos. Nada é descartado por padrão: o drop está ali para uma regra que você acrescenta, quando até a presença de um campo é mais do que você quer guardar.

o que voltou
$ curl -s "$MCPD/v1/api/traces/$TRACE" | jq .spans[0].input{  "question": "Where is my order A-1042?",  "api_key": "[redacted]",  "email": "al…om",  "user_id": "sha256:9f2c1b7d4a08e5c3f6b1d0a4"}

Por que o hash usa uma chave

O hash mantém um campo útil. Entradas iguais dão hashes iguais sob uma mesma chave, então você ainda consegue agrupar por um id de usuário ocultado, contar quantas pessoas distintas bateram em uma falha, e seguir uma delas ao longo de uma sessão, sem guardar quem elas são.

Sem chave, essa propriedade vira um buraco. Identificadores vêm de espaços pequenos e adivinháveis, e quem tiver o hash e este código-fonte pode testar candidatos até um deles bater. A chave é o que torna o hash irreversível, em vez de apenas inconveniente.

A chave embutida é uma constante no código-fonte, ou seja, é pública. Defina a sua própria em qualquer implantação que aplique hash em alguma coisa.

uma implantação que aplica hash e expira dados

mcpd --redact-key "$MCPD_REDACT_KEY" --retention-days 30

Rotacionar a chave quebra o vínculo de propósito. Todo hash muda, então as linhas escritas antes da rotação não podem mais ser ligadas às linhas escritas depois dela. É para isso que a rotação serve, e é também o que ela custa. Rotacione a chave quando você quiser o vínculo cortado, não por hábito de calendário.

Leia a segunda metade desse custo antes de rotacionar a chave. O apagamento por titular casa o hash armazenado, então toda linha escrita sob a chave antiga fica fora do alcance de um pedido de exclusão a partir do momento em que a chave nova entra em vigor. O mcpd não tem como avisar que foi isso que aconteceu, porque nada registra qual chave escreveu qual linha. Tudo o que ele pode fazer é se recusar a deixar o zero resultante passar por resposta, e a seção sobre o zero, mais abaixo, é essa recusa.

Todo sinal, não só spans

Um segredo vaza tão fácil por uma linha de log quanto por um prompt, então o mesmo ocultador roda sobre os seis sinais na entrada:

  • Spans: input, output, attributes e metadata.
  • Logs: os fields estruturados.
  • Métricas: os labels.
  • Notas: o metadata.
  • Feedback: o metadata e o comment de texto livre.
  • Notas de aspecto: a frase, em text.

O nome de um span e a mensagem de um registro de log não passam pelo ocultador. Eles são identificadores, não payloads, e reescrevê-los quebraria o agrupamento. Não monte o nome de um span com o e-mail de um cliente.

O armazenamento de execuções não é ocultado

As execuções duráveis são a exceção, e de propósito. O armazenamento de execuções guarda a entrada, o estado, a saída e a correlação de uma execução, e a entrada, a saída e o erro de cada etapa, exatamente como foram enviados, com o user_id sem hash. Eles são o estado de trabalho do workflow: uma etapa posterior os lê de volta, e um valor que o ocultador tivesse reescrito mudaria o que a execução faz. Os spans que uma execução escreve no armazenamento de traces seguem o caminho de ingestão comum, então o armazenamento de traces os guarda ocultados e com hash, como qualquer outro span. Tanto a retenção quanto o apagamento alcançam o armazenamento de execuções, como dizem as seções abaixo, e toda leitura de uma execução pela API é registrada no log de auditoria.

Textos longos e documentos profundos

Dois limites valem para tudo, independentemente de alguma regra ter casado.

Qualquer texto único maior que 128 KiB é truncado, com … [N bytes truncated] no fim, para que quem lê saiba que o valor está incompleto, e não que ele é curto. O corte cai em um limite de caractere, nunca dentro de um ponto de código de vários bytes. Um prompt descontrolado não deve decidir o tamanho do seu banco de dados.

A varredura para em uma profundidade de 32, e tudo abaixo disso é trocado por [too deeply nested]. Um documento patológico ou que se referencia a si mesmo, chegando em uma porta de ingestão aberta, não consegue prender uma CPU.

Retenção

O --retention-days, ou MCPD_RETENTION_DAYS, apaga sinais mais antigos que essa quantidade de dias. Omita a opção e nada é apagado. Guardar os dados por padrão é o sentido certo: uma ferramenta que descartasse em silêncio os traces do mês passado seria descoberta no pior momento possível.

A passagem roda em um temporizador de hora em hora dentro do daemon, não na hora da consulta. Uma varredura de retenção pode apagar um número enorme de linhas, e esse trabalho nunca pode cair dentro da requisição de alguém. Cada passagem remove, em uma única transação, os spans, logs, métricas e linhas de trace cujo received_at é mais antigo que o corte, e as notas, o feedback e as notas de aspecto cujo próprio carimbo de tempo é, e depois anota quantas linhas foram embora. As entradas do índice de texto completo são limpas primeiro, enquanto as linhas que as nomeiam ainda existem, então nada fica órfão.

A mesma passagem, na mesma janela, corta o armazenamento de execuções. Ela apaga toda execução durável cuja raiz terminou antes do corte, com as execuções filhas dela e tudo o que está guardado para elas, em lotes, para que os workers não fiquem parados enquanto ela trabalha. Uma execução que não terminou nunca é apagada, por mais tempo que esteja esperando. As definições não são cortadas.

A passagem cobre o projeto indicado por --project e todo projeto no bloco projects do arquivo de configuração, não só o padrão. Um projeto que define ali o seu próprio retention_days é varrido por esse número, e os demais são varridos pelo número do daemon. Um projeto que não está em nenhum dos dois lugares, criado por um remetente por conta própria, não tem janela e nunca é cortado. Isso importa quando um daemon serve vários times: um time que combinou guardar trinta dias não passa a guardar mais em silêncio porque a configuração de outro time era a única que a varredura leu, e um time que ninguém anotou guarda tudo.

Um pedido de apagamento sobre uma pessoa

O DELETE /v1/api/subjects/{user_id} responde a um pedido sobre uma pessoa em uma chamada. Envie o identificador do jeito que a sua instrumentação o definiu. O daemon aplica o mesmo hash que aplicou na ingestão, então uma implantação que aplica hash em user_id não exige que você aplique hash antes.

A unidade do apagamento é a execução, não a linha. Em geral só um span raiz carrega um user_id, e os spans filhos é que guardam o prompt e a resposta, então apagar as linhas que nomeiam o titular deixaria o payload de pé. Uma execução entra no alcance quando a projeção do trace dela nomeia o titular, ou quando qualquer span dela o nomeia, e então a execução inteira vai embora:

  • Todo span daquela execução, com os inputs e outputs que ele carregava.
  • Os logs, métricas, notas, feedback e notas de aspecto pendurados nele.
  • Sinais que nomeiam um span_id e nenhum trace_id. Uma nota ou uma linha de log presa a uma etapa da execução do titular é alcançada pelos ids de span das execuções em alcance, e não só pelos ids de trace delas.
  • Linhas de log registradas em nome da pessoa sem execução em que se pendurar, casadas pelo user_id delas mesmas.
  • O feedback que a pessoa escreveu, casado por author_id, esteja ele pendurado onde estiver. Essa linha guarda a prosa dela, e pode estar pendurada na execução de outra pessoa, que é onde ela costumava sobreviver.
  • Casos de dataset capturados de uma execução em alcance. Esses são esvaziados, e não apagados. A seção depois da próxima diz por quê.
  • Em um daemon que serve execuções duráveis, toda execução terminada cuja correlação carrega o user_id, casado do jeito que você iniciou a execução com ele, porque o armazenamento de execuções o guarda sem hash, com a árvore inteira de execuções filhas e tudo o que o armazenamento de execuções guarda para elas. Uma execução dessa pessoa que não terminou não é tocada: apagá-la corromperia o workflow de que ela faz parte. Ela é contada em runs_in_flight e nomeada em note, para que você possa cancelá-la, ou deixá-la terminar, e mandar o pedido de novo.

O armazenamento de execuções é apagado primeiro. Os spans de uma execução esperam no outbox dela até serem escritos no armazenamento de traces, então apagar as execuções primeiro apaga todo span que ainda espera, e o apagamento no armazenamento de traces remove depois os que já foram escritos.

O que a resposta carrega

Uma contagem por sinal, duas contagens para o armazenamento de execuções, e seis campos ao lado delas carregando o que uma contagem não consegue: o que foi deixado para trás de propósito, e se um número pequeno pode ser lido como "não há nada sobre esta pessoa aqui". deleted é o total das seis contagens de sinal; as contagens de execução e aspect_notes_erased são informadas ao lado dele, e não somadas a ele.

um pedido, uma pessoa
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq{  "project": "checkout",  "traces": 1,  "spans": 2,  "logs": 1,  "metrics": 0,  "scores": 1,  "feedback": 1,  "deleted": 6,  "runs_erased": 1,  "runs_in_flight": 0,  "dataset_items_erased": 1,  "aspect_notes_erased": 0,  "datasets": [    "checkout-regressions"  ],  "deletes_pending": false,  "unmatchable_identifiers": 0,  "note": "1 captured dataset cases were emptied in place rather           than deleted, so a regression test is not destroyed           without anyone being told. They hold no payload any           more."}
CampoO que ele diz a você
runs_erased Execuções duráveis apagadas do armazenamento de execuções, execuções filhas incluídas. Não entram em deleted. Ausente quando o daemon não serve execuções
runs_in_flight As execuções duráveis da pessoa deixadas inteiras porque não terminaram. note nomeia as execuções a cancelar. Ausente quando o daemon não serve execuções
dataset_items_erased Casos capturados esvaziados no lugar. Eles não entram em deleted, porque as linhas continuam ali
aspect_notes_erased Notas de aspecto apagadas. Não entram em deleted, que soma as seis contagens de sinal impressas acima dele
datasets Em quais datasets esses casos estão, para você poder ir olhar
deletes_pending Verdadeiro quando o backend aplica as exclusões depois de responder, de modo que as contagens dizem o que casou, e não o que já saiu do disco
unmatchable_identifiers Identificadores armazenados neste projeto que estão em um formato que este pedido não teria como casar
note Por que os números acima podem não ser a resposta inteira. Ausente quando são

Casos capturados são esvaziados, não apagados

Um caso de dataset capturado da produção guarda uma cópia literal do input, então apagar a execução e deixar a cópia deixaria as palavras da pessoa dentro do dataset. Apagar o caso de vez é um dano de outra natureza: alguém capturou aquela execução para impedir que um defeito voltasse, e destruir o teste de regressão dessa pessoa sem dizer nada não é uma decisão que o mcpd deva tomar sozinho.

Então o payload é sobrescrito e a linha sobrevive. O caso passa a ler {"erased": true} onde estavam o input e o metadata dele, o vínculo de volta com a execução de origem é limpo, e a resposta diz quantos casos e quais datasets. O caso fica visivelmente vazio, que é algo sobre o qual quem opera consegue agir.

Um caso fica completamente fora de alcance, e o isolamento por projeto é a razão. Um caso capturado para um dataset que pertence a outro projeto não é esvaziado e não é contado. A chamada é autorizada para um projeto e toca um projeto, e um apagamento que atravessasse essa fronteira seria um jeito de descobrir o que há em um dataset que você não pode ler. Apague a pessoa daquele projeto também, com uma chave que o alcance.

O que ele não faz

Ele toca um projeto. O ?project= nomeia esse projeto, e o projeto padrão do daemon é usado quando você o omite. Uma chave que não alcança esse projeto é recusada com um 403 e nada é apagado. Linhas da mesma pessoa em um projeto que a chave não abre não são tocadas nem contadas, então uma contagem nunca pode revelar que o titular existe em outro lugar. Apagar uma pessoa de vários projetos é uma chamada por projeto, com uma chave que alcance cada um deles.

Um titular sem nada armazenado é um 200 com zeros, não um 404. O estado que você pediu vale, e um 404 responderia uma pergunta que ninguém tem direito de fazer: se aquela pessoa existe em algum lugar que esta chave enxerga. É também por isso que repetir depois de um tempo esgotado é seguro. A segunda chamada informa as contagens menores de uma segunda passagem, em vez de um erro.

Ele não é um jeito de apagar uma pessoa dos seus próprios sistemas. O mcpd apaga o que o mcpd armazenou. Qualquer coisa que você encaminhou para outro endpoint OTLP precisa ser apagada lá também, e o mcpd não sabe o que aconteceu com ela.

Chaves e o registro disso

O apagamento exige uma chave admin quando há chaves configuradas, e recua para as chaves de leitura quando nenhuma chave admin está definida, para que uma implantação existente mantenha o alcance que tinha. Ele é escrito no log de auditoria com a ação, o projeto e um resumo da chave que pediu, e uma tentativa recusada é registrada do mesmo jeito. Onde o user_id passa por hash, o hash é o que a linha nomeia: o log de auditoria sobrevive aos dados que descreve, então escrever o identificador cru ali deixaria o nome que a pessoa pediu para você remover em uma tabela que a retenção não varre.

Uma execução por vez continua disponível. O DELETE /v1/api/traces/{trace_id} remove uma única execução e tudo o que está ligado a ela, em uma transação, devolvendo o número de linhas apagadas.

Um zero vindo de um apagamento é o valor de que desconfiar

Um zero é o que quem opera repete para uma pessoa como "não temos nada sobre você", e é o único número que este endpoint não consegue provar. Os identificadores armazenados passam por hash com a chave de ocultação, e nada registra qual chave aplicou hash em qual linha, então um zero pode igualmente significar que a chave mudou depois que aquelas linhas foram escritas. O mcpd não consegue distinguir os dois casos. O que ele consegue é nunca devolver um zero pelado que se leia como o primeiro deles.

Aqui está o mesmo titular, apagado sob uma chave de ocultação que foi rotacionada depois de a execução ter sido registrada. As contagens são todas zero e a linha continua ali:

depois de uma rotação, o mesmo pedido não casa nada
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{  "deleted": 0,  "unmatchable_identifiers": 0,  "note": "Nothing matched. Stored identifiers are hashed with           the redaction key, so this means no stored identifier           hashes to this subject under the key in force now. It           does not mean nothing about this person was ever           stored: rows written under a different redaction key           cannot be matched by this endpoint."}

O que a nota pode provar depende do projeto. Quando nada nele está atribuído a alguém, não há linha que uma troca de chave pudesse estar escondendo, e a nota diz exatamente isso. Caso contrário, o zero não está provado: restaure a chave de ocultação sob a qual aquelas linhas foram escritas e repita o pedido.

Ele informa em vez de recusar, e essa é uma troca deliberada. Recusar quebraria uma repetição depois de um tempo esgotado, faria a resposta para "nunca visto" ficar diferente da resposta para "apagado há uma hora" e transformaria o endpoint em um jeito de perguntar se uma pessoa é conhecida por um projeto, e bloquearia todo apagamento em uma implantação cuja configuração de ocultação um dia mudou, inclusive os apagamentos que corretamente não casaram nada.

Identificadores que este pedido não teria como casar

Uma rotação é invisível. Uma mudança em se os identificadores passam por hash já não é, e o unmatchable_identifiers é o que o mcpd consegue enxergar. Um identificador com hash é reconhecível pelo prefixo sha256:, então o armazenamento conta quantos dos identificadores que ele guarda estão no formato oposto ao que este pedido trouxe: linhas em texto puro contra um pedido com hash, ou o contrário. Aquelas linhas foram escritas sob outra configuração de ocultação, podem pertencer ao titular, e este pedido não consegue endereçá-las.

A contagem roda em todo apagamento, não só quando dá zero, e a resposta abaixo é o motivo. Este projeto recebeu gravações com a ocultação ligada, e depois recebeu gravações de novo com ela desligada. O pedido casou as linhas mais novas e as apagou, e os dois identificadores mais antigos têm hash, de modo que este pedido nunca teria como endereçá-los. Uma contagem que parasse no deleted teria sido lida como uma resposta completa:

um apagamento parcial, que é o caso que vale pegar
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{  "deleted": 2,  "unmatchable_identifiers": 2,  "note": "2 stored identifiers in this project are in a form           this request could not match, so rows written under a           different redaction setting were not searched and rows           about this person may remain. Restore the redaction           setting those rows were written under and repeat the           request."}

A contagem é sobre todo identificador que o projeto guarda, e não sobre as linhas do próprio titular, então aqueles dois podem pertencer a outra pessoa inteiramente. É o máximo que ela consegue dizer. O que ela descarta é ler um apagamento de aparência bem-sucedida como um apagamento completo.

A garantia não é a mesma nos três backends

SQLite e Postgres apagam dentro da transação que respondeu a você. Quando a resposta chega, as linhas já se foram, e o deletes_pending é false.

O ClickHouse não faz assim. A exclusão ali é uma mutação assíncrona: o servidor aceita o comando e o aplica em segundo plano, então a resposta diz o que casou, e não o que já saiu do disco. Esses apagamentos voltam com deletes_pending em true, que é o mcpd avisando que a mais fraca das duas garantias é a que está em vigor. Os casos de dataset são a exceção nesse backend. Eles são reescritos sob a própria chave, em vez de mutados, então o caso esvaziado é o que qualquer leitura devolve a partir do instante em que a resposta é enviada. Uma mutação teria deixado o texto antigo legível até terminar.

Se você precisa certificar a alguém que os dados sumiram até um dado momento, essa é a diferença para anotar. No ClickHouse, confirme que a mutação terminou no servidor antes de responder.

Antes de a porta ficar acessível

A ocultação protege os valores dentro de um trace. Ela não faz nada quanto a quem pode ler o trace. O mcpd avisa na inicialização quando se liga a um endereço fora do loopback com a ingestão aberta ou a API de leitura aberta, porque os prompts e as respostas armazenados ficam então legíveis para qualquer um que alcance a porta. Defina --ingest-key e --read-key primeiro, e leia Docker e Kubernetes.

A seguir