Estados — a atividade executada (spo_performed_project_activities)¶
A maior tabela da ontologia, e a única que nunca é atualizada. Este documento existe porque "nunca atualiza" é decisão de desenho, e uma decisão que não está escrita vira bug relatado por quem chega.
Maior pela última medida publicada: 30 560 linhas no banco de desenvolvimento em 2026-09-12 (mapa das tabelas), contra 2 559 da segunda maior entre as tabelas
spo_,sro_,eo_ecmpo_. Não foi remedida nesta data — não havia acesso ao banco — e o número só cresce.
O que ela guarda¶
Uma ocorrência: um commit que aconteceu, um rótulo que foi posto, um cartão que mudou de coluna. O schema diz que ela é o kind de todas as ocorrências da rede:
"A ontologia diz que commits, execuções de teste, cerimônias, implantações e inspeções 'compartilham o mesmo princípio de identidade', e por isso este schema é modelado pelo critério do conceito, não pela timeline do GitHub — que é só a primeira origem a chegar aqui." —
performed_project_activity.ex:5-9
O estado não é da linha — é do que aconteceu ao gravá-la¶
Aqui a máquina é diferente de todas as outras desta pasta, e a diferença é o ponto:
Não existe coluna de estado, porque não existe estado. Uma ocorrência aconteceu; ela não passa por situações.
O que existe é um campo virtual — outcome — que não é gravado em lugar nenhum e só
descreve o desfecho da tentativa de gravar:
performed_project_activity.ex:77-79
Doze schemas da plataforma têm outcome. Onze deles usam [:created, :updated, :unchanged].
Este é o único com quatro valores, e o único sem :updated — e a ausência é a decisão:
"Os outros schemas da plataforma têm três resultados porque descrevem entidades que mudam: uma pessoa troca de nome, um repositório é arquivado. Uma ocorrência não muda — ela aconteceu." —
performed_project_activity.ex:12-15
A máquina¶
Os estados são os quatro desfechos de record_activity/2. O que decide entre eles é a
identidade — internal_id, um hash dos componentes que a ontologia declara.
stateDiagram-v2
direction TB
[*] --> procura : record_activity(tenant, attrs)
state "procura pela identidade NOVA" as procura
state "procura pela identidade SEM o identificador" as procura_antiga
procura --> completada : achou\n(e falta campo completavel)
procura --> inalterada : achou\n(e nada falta)
procura --> procura_antiga : nao achou
procura_antiga --> promovida : achou linha sem source_external_id\n(update_all afetou 1)
procura_antiga --> criada : nao achou\nou a corrida levou a linha
criada --> inalterada : violacao do indice unico\n(outra escrita gravou a mesma)
completada --> [*]
inalterada --> [*]
promovida --> [*]
criada --> [*]
note right of inalterada
O desfecho :unchanged NAO escreve nada.
E o que faz reprocessar a mesma
origem produzir UMA linha (FR-003).
end note
| Desfecho | O que aconteceu na linha | Onde |
|---|---|---|
:created |
linha nova | commands.ex:160 |
:unchanged |
nada foi escrito | commands.ex:97 e :175 |
:promoted |
a linha existente recebeu o source_external_id que a origem sempre deu |
commands.ex:145 |
:completed |
campos nulos da linha existente foram preenchidos | commands.ex:104 |
As duas exceções aparentes, e por que não são¶
:promoted e :completed escrevem em linha existente. O @doc de record_activity/2 trata
disso de frente, e a defesa é a mesma nos dois casos:
"A ocorrência é a mesma — mesmo tipo, mesmo ator, mesmo instante, mesmo sujeito. O que muda é o que sabemos escrever sobre ela." —
commands.ex:43-44
Complementação (:completed)¶
Preenche campo nulo com o que a origem sempre disse e a consulta não pedia. A lista é fechada — três campos, todos observação da origem, nada derivado por nós:
commands.ex:78
A guarda que a torna segura está na condição: só entra campo em que is_nil dos dois lados —
existente nulo, valor novo não nulo. Trocar valor existente segue proibido.
O dado que a exigiu, medido em 2026-09-16: a recoleta de 25 repositórios "passou inteira
sem gravar um único quadro, porque toda ocorrência já existia e :unchanged não escreve nada"
(commands.ex:86-87). Sem a complementação, acrescentar campo à consulta não alcança o
histórico — e isso é a família do sucesso silencioso: a coleta termina verde e não gravou o
que se foi buscar.
Promoção (:promoted) — e ela é transitória¶
Até 2026-09-15 a coleta não pedia à origem o identificador do evento de timeline, "por
acreditar que ele não existia. Ele existe" (commands.ex:34-35). O identificador entrou no
critério de identidade, e 41 863 linhas já gravadas estavam sem ele — relidas da origem,
calculariam hash diferente e entrariam de novo, como 41 863 duplicatas.
A promoção evita isso: não achando pela identidade nova, procura pela identidade que a ocorrência teria sem o identificador; se a linha existe e está sem ele, recebe o identificador e passa a valer pela identidade nova.
Ela também separa ocorrências que estavam coladas: dois rótulos postos na mesma issue, no
mesmo segundo, pelo mesmo ator dividiam uma linha só. O primeiro a chegar promove a linha; o
segundo não a acha mais pela identidade antiga, e é inserido. Duas linhas para dois atos
(commands.ex:46-49).
Como saber quando apagar o ramo — está escrito no próprio @doc, e é o tipo de critério
que evita código transitório virar permanente:
"Enquanto esse número não for o das origens que legitimamente não identificam seus eventos, a promoção ainda tem trabalho." —
commands.ex:56-57
As duas corridas, e como cada uma é resolvida¶
Não são hipóteses: as duas estão tratadas no código, com o comentário explicando por quê.
| Corrida | Onde | Como se resolve |
|---|---|---|
| duas gravações disputam a mesma linha antiga para promover | commands.ex:126-152 |
o where ... is_nil(source_external_id) no update_all: só uma promove; a outra recebe zero linhas afetadas e segue para o insert |
| duas coletas da mesma issue chegam juntas ao insert | commands.ex:157-186 |
o índice único responde, e só a violação de :internal_id é tratada — qualquer outro erro sobe, porque engolir seria fallback silencioso (commands.ex:165-168) |
A segunda merece destaque, porque é o desenho contrário ao que a casa persegue como defeito:
o rescue genérico teria transformado qualquer erro em :unchanged, e a coleta reportaria
sucesso sem ter gravado. O código trata uma violação nomeada e deixa o resto falhar.
A identidade, e a emenda de 2026-09-15¶
internal_id é o hash dos componentes que a ontologia declara, "na ordem em que a ontologia
os escreveu, e mudá-la mudaria toda identidade já gravada"
(performed_project_activity.ex:126-127).
A versão 1 não incluía o sujeito, e a consequência foi medida: a issue #2539 do
conectafapes-project tem 12 eventos na origem e 7 no banco; o de 2026-08-12 15:12:16
não entrou porque a identidade estava ocupada pela #2536, que mudou de coluna no mesmo
instante, pelo mesmo ator (20260915220000:17-22).
A migração 20260915220000_identidade_da_atividade_v2.exs recalcula internal_id de toda
linha, com o sujeito incluído. Duas coisas dela merecem estar num modelo:
- É reversível, e sem perda. O hash novo é mais específico que o antigo: o que já era
distinto continua distinto. A colisão só podia acontecer no sentido oposto
(
20260915220000:31-35). - Ela não recupera os eventos perdidos. "Eles nunca foram gravados. O que ela devolve é a
possibilidade de gravá-los" (
20260915220000:38-40) — e quem os traz é a recoleta.
Duas datas que valem para quem lê um número desta tabela:
| Instante | O que muda |
|---|---|
| 2026-09-15 | subject_type e subject_id entram na identidade; todo internal_id é recalculado |
| 2026-09-16 | board_id, board_external_id e status_name passam a existir; status_name é preenchido retroativamente a partir de payload->>'status' (20260916140000:53-57) |
board_id não foi preenchido retroativamente — "O quadro não estava, e só volta pela
recoleta" (20260916140000:51-52). É exatamente o que a complementação existe para fazer.
Um nulo que significa¶
concept_id nulo não é dado faltando: significa que a rede não nomeia aquele tipo de
atividade. É o estado honesto de labeled e cross-referenced
(performed_project_activity.ex:17-19).
Quem declara o contrário disso é a organização, e é uma das tabelas de
declaração revogável: spo_event_concept_declarations prevalece
sobre o padrão da casa na leitura, sem regravar nada aqui.
O que este modelo não mostra¶
- Os campos de proveniência e o
payload— estão no diagrama de classes. board_id×board_external_ideperformer_id×performer_login: o par existe porque "o identificador da origem sempre cabe, e a resolução para o quadro observado pode não existir ainda" (performed_project_activity.ex:53-56). É modelo de dado, não de estado.- Os quatro desfechos não são transições de um registro: são desfechos de uma chamada. Por isso a tabela abaixo é de desfecho, e não de seta.
Cada desfecho, e o teste que o prova¶
test/the_band/ontology/seon/spo/atividade_test.exs tem 20 testes, e os quatro desfechos
aparecem nas asserções:
| Desfecho | Asserção | Linha |
|---|---|---|
:created |
assert primeira.outcome == :created |
atividade_test.exs:75 |
:unchanged |
"a segunda gravação não duplica, e diz :unchanged" |
atividade_test.exs:73, 78 |
:promoted |
assert depois.outcome == :promoted |
atividade_test.exs:226, 248 |
:completed |
assert depois.outcome == :completed |
atividade_test.exs:372 |
Dois testes merecem ser conhecidos por quem mexer aqui, porque protegem contra o defeito que esta casa mais persegue:
atividade_test.exs:203-206— "O segundo tenant recebeu:unchangedde um evento que ele nunca viu": prova que a identidade isola tenants. Sem isso, a coleta de uma organização descartaria em silêncio os eventos de outra.atividade_test.exs:83— a asserção da FR-003 é a contagem de linhas, e não ooutcome. O comentário do próprio teste explica: um:unchangedpode estar certo pelo motivo errado, e a contagem é o que não mente.