Estados — o projeto declarado e seus vínculos¶
spo_projects é a tabela que ninguém coleta. Toda linha dela foi escrita por uma pessoa na
tela, e por isso toda transição tem autor. É o contraponto exato de
observacao.md, onde ninguém decidiu nada.
Cuidado com o nome. O GitHub chama de project o quadro, que nesta base é
observed_projects.spo_projectsé o projeto declarado pela organização. A colisão está registrada no código: "O GitHub chama o quadro de 'project', e é daí que vem a colisão de nomes" (projects.ex:155-156).
Duas máquinas: o projeto, e os vínculos dele¶
1. O projeto (spo_projects.removed_at)¶
stateDiagram-v2
direction LR
[*] --> declarado : create_project/3\n(created_by_user_id)
declarado --> declarado : update_project/4\n(updated_by_user_id)
declarado --> removido : remove_project/3\n[sem subprojetos vigentes]
removido --> [*]
note right of removido
ESTADO FINAL. "Nao existe undelete —
declarar de novo e criar de novo,
com autor novo." projects.ex:281
end note
Este é o único estado final desta pasta que não volta. Em todas as outras máquinas a marca se limpa ou se redeclara; aqui não:
"Não existe undelete — declarar de novo é criar de novo, com autor novo." —
lib/the_band/ontology/seon/spo/projects.ex:281
E ainda assim marca, e não apaga — a linha fica com removed_at e removed_by_user_id
(20260816210000:6-7).
A guarda que impede a cascata¶
remove_project/3 devolve {:error, :has_parts} quando existem subprojetos vigentes:
"as partes são movidas ou removidas primeiro, porque remover em cascata apagaria declarações que ninguém pediu para apagar." —
projects.ex:279-281
É uma recusa explícita, e não um delete_all silencioso. Vale conhecer porque é a decisão
oposta à que a FK faria sozinha.
O nome, e a liberação que a remoção provoca¶
spo_projects tem índice único em (tenant_id, name)
(20260815160000_create_spo_projects.exs:45). A migração
20260826030000_nome_de_projeto_removido_libera.exs existe justamente para que remover um
projeto libere o nome — sem ela, um nome removido ficaria ocupado para sempre, e quem
tentasse redeclarar receberia um erro sem explicação.
2. Os quatro vínculos (linked_at / unlinked_at)¶
spo_project_organizations, spo_project_teams, spo_project_repositories e
spo_project_boards têm exatamente o mesmo desenho, e a migração diz que foi de propósito:
"os dois vínculos novos têm o desenho do vínculo com repositório —
linked_by/at,unlinked_by/at, religar cria linha nova — porque a história dos vínculos é o dado." —20260816210000_project_lifecycle_and_links.exs:7-9
| Estado | A regra, exatamente |
|---|---|
| vigente | unlinked_at é nulo |
| desfeito | unlinked_at preenchido, com unlinked_by_user_id |
| nunca vinculado | nenhuma linha para o par |
stateDiagram-v2
direction LR
[*] --> nunca_vinculado
nunca_vinculado --> vigente : link_*/4\n(linked_by_user_id, linked_at)
vigente --> desfeito : unlink_*/3\n(unlinked_by_user_id, unlinked_at)
desfeito --> vigente : link_*/4 de novo\n(LINHA NOVA — ver a excecao abaixo)
note right of desfeito
A linha FICA: "desde quando este quadro
e deste projeto" so tem resposta se o
encerramento preservar o comeco.
projects.ex:198-199
end note
A exceção que vale conhecer: o quadro revive o vínculo¶
Três dos quatro criam linha nova ao religar. link_board/4 não:
"Reassociar um quadro que saiu revive o vínculo encerrado em vez de criar outro: o índice único é parcial sobre os vigentes, e duas linhas vigentes para o mesmo par não podem existir." —
projects.ex:171-173
Lendo o código: link_board/4 procura um vínculo vigente e, achando, devolve o existente
(projects.ex:188-190) — é idempotente. Um vínculo desfeito não é revivido por este ramo;
o insert cria linha nova, e o índice parcial permite, porque a antiga não é vigente.
✅ RESOLVIDO em 2026-09-18 — o comentário foi corrigido, o código não. A primeira leitura venceu: o corpo entrega idempotência sobre o vigente, que é o comportamento certo. Reviver a linha encerrada apagaria o registro de que o quadro saiu e voltou, e ausência marca, nunca apaga. O que estava errado era a frase.
A divergência entre o comentário e o código está registrada aqui, e não resolvida: o
comentário diz "revive o encerrado", o corpo procura is_nil(unlinked_at). As duas leituras:
- o comentário está adiantado — descreve a intenção, e o corpo entrega idempotência sobre o vigente, que é o que importa na prática;
- o comentário está certo e falta código — reassociar deveria limpar
unlinked_atda linha antiga em vez de criar outra.
Levar a quem mantém SPO.Projects. O efeito observável é a quantidade de linhas no
histórico de vínculos, e nenhuma consulta de vigência muda.
Os quatro índices parciais¶
Cada um carrega a invariante "um vínculo vigente por par", e todos são parciais sobre
unlinked_at IS NULL — sem isso, desfazer e refazer seria impossível:
| Tabela | Chave | Índice |
|---|---|---|
spo_project_organizations |
tenant, projeto, organização |
spo_project_organizations_vigente_index (20260816210000:49-51) |
spo_project_teams |
tenant, projeto, equipe |
spo_project_teams_vigente_index (20260816210000:72-74) |
spo_project_repositories |
tenant, projeto, repositório observado |
spo_project_repositories_vigente_index (20260815160000:70-74) |
spo_project_boards |
tenant, projeto, quadro observado |
spo_project_boards_vigente_index (20260824120000:73-77) |
Por que a história do vínculo é o dado¶
Não é zelo de auditoria: é o que permite responder "o que esta equipe entregou enquanto
esteve neste projeto". team_project_links_with_period/2 (projects.ex:514) existe
exatamente para isso, e test/the_band/ontology/seon/spo/periodo_de_participacao_test.exs é o
teste do período.
Apagar o vínculo desfeito tornaria a pergunta incomputável, e a medida passaria a atribuir à equipe o trabalho de um período em que ela já não estava — ou nenhum.
Um projeto tem mais de um quadro¶
Decisão da pessoa mantenedora, 2026-08-24, com a medida junto:
"Não há 'o quadro do projeto': há os quadros dele. O Conecta Fapes tem quatro, e lê-lo por um só fazia dez meses de entrega sumirem." —
projects.ex:152-153
É o tipo de fato que um diagrama de cardinalidade mostra como 1 --> 0..* e não explica. Aqui
fica explicado.
Onde cada transição acontece¶
| Transição | Função |
|---|---|
| → declarado | projects.ex:43 |
| declarado → declarado | projects.ex:259 |
| declarado → removido | projects.ex:285 |
| vínculo com organização: ligar / desligar | projects.ex:321 / :350 |
| vínculo com equipe: ligar / desligar | projects.ex:387 / :416 |
| vínculo com repositório: ligar / desligar | projects.ex:92 / :132 |
| vínculo com quadro: ligar / desligar | projects.ex:162 / :203 |
Declaração de cobertura: os testes existem em
test/the_band/ontology/seon/spo/{gestao_do_projeto,projetos,periodo_de_participacao}_test.exs;
o mapeamento transição a transição não foi feito neste documento, e fica declarado como
lacuna para QA. A guarda :has_parts é a mais importante a conferir, porque é a única que
recusa.
O que este modelo não mostra¶
parent_ide a hierarquia de subprojetos (set_parent/3,clear_parent/2). É relação, não estado; está emclasses/projetos-e-processo.md.- Os campos do projeto (nome, descrição). Mesmos documentos.
- O quadro observado (
observed_projects), que tem ciclo próprio:closed(booleano, cópia da origem) eno_longer_observed_at— este último emobservacao.md.