Banco — projetos e processo¶
17 tabelas de 63. Recorte declarado em
mapa-das-tabelas.md.
A colisão de nome vem antes do diagrama: spo_projects é o projeto — o empreendimento
que uma pessoa declara. observed_projects é o quadro — o Projects v2 que a coleta traz.
O GitHub chama o segundo de "project", e é daí que vem a confusão
(lib/the_band/ontology/seon/spo/schemas/activity_start_criterion.ex:15-21). São tabelas
diferentes, e os CHECK de alvo único existem para impedir que se misturem.
1. O projeto declarado¶
erDiagram
spo_projects |o--o{ spo_projects : contém
spo_projects ||--o{ spo_project_organizations : "pertence a"
spo_projects ||--o{ spo_project_teams : "é feito por"
spo_projects ||--o{ spo_project_repositories : "produz em"
spo_projects ||--o{ spo_project_boards : "é acompanhado em"
spo_projects ||--o{ spo_activity_start_criteria : "declara início por"
spo_projects ||--o{ spo_activity_deadline_criteria : "declara prazo por"
eo_organizations ||--o{ spo_project_organizations : reúne
eo_teams ||--o{ spo_project_teams : "trabalha em"
observed_repositories ||--o{ spo_project_repositories : recebe
observed_projects ||--o{ spo_project_boards : acompanha
observed_projects ||--o{ spo_activity_start_criteria : "declara início por"
observed_projects ||--o{ spo_activity_deadline_criteria : "declara prazo por"
eo_people |o--o{ spo_performed_project_activities : realizou
spo_projects |o--o{ spo_performed_project_activities : "é contexto de"
spo_projects {
uuid id PK
uuid tenant_id FK
string name
date started_on
date ended_on "nulo = em curso"
uuid parent_id FK "nulo = projeto de topo"
uuid declared_by_user_id FK
uuid updated_by_user_id FK
datetime removed_at "nulo = existe"
uuid removed_by_user_id FK
}
spo_project_organizations {
uuid id PK
uuid tenant_id FK
uuid project_id FK
uuid organization_id FK
uuid linked_by_user_id FK
datetime linked_at
uuid unlinked_by_user_id FK
datetime unlinked_at "nulo = vale"
}
spo_project_teams {
uuid id PK
uuid tenant_id FK
uuid project_id FK
uuid team_id FK
uuid linked_by_user_id FK
datetime linked_at
datetime unlinked_at "nulo = vale"
}
spo_project_repositories {
uuid id PK
uuid tenant_id FK
uuid project_id FK
uuid observed_repository_id FK
uuid linked_by_user_id FK
datetime linked_at
datetime unlinked_at "nulo = vale"
}
spo_project_boards {
uuid id PK
uuid tenant_id FK
uuid project_id FK
uuid observed_project_id FK
uuid linked_by_user_id FK
datetime linked_at
datetime unlinked_at "nulo = vale"
}
spo_activity_start_criteria {
uuid id PK
uuid tenant_id FK
uuid project_id FK "XOR com observed_project_id"
uuid observed_project_id FK
string event_type "cru, sem enum"
uuid declared_by_user_id FK
datetime declared_at
datetime revoked_at "nulo = vale"
}
spo_activity_deadline_criteria {
uuid id PK
uuid tenant_id FK
uuid project_id FK "XOR com observed_project_id"
uuid observed_project_id FK
string source "board_field, sprint ou milestone"
string field_name "só quando source = board_field"
uuid declared_by_user_id FK
datetime declared_at
datetime revoked_at "nulo = vale"
}
spo_performed_project_activities {
uuid id PK
uuid tenant_id FK
string internal_id
uuid organization_id FK
uuid project_id FK
string activity_type
string concept_id "nulo = a rede não nomeia"
uuid performer_id FK
string performer_login
datetime occurred_at
string subject_type
uuid subject_id
json payload
}
2. O quadro observado, a iteração, o sprint¶
erDiagram
connected_tools ||--o{ observed_projects : alcança
observed_projects ||--o{ project_items : lista
observed_projects ||--o{ project_field_definitions : define
observed_projects ||--o{ project_iterations : configura
observed_projects ||--o{ smpo_iteration_field_roles : "tem o papel declarado de"
project_items ||--o{ item_field_values : carrega
project_field_definitions ||--o{ item_field_values : tipa
collected_issues |o--o| project_items : "aparece como"
sro_sprints |o--o| project_iterations : "é a leitura de"
spo_intended_project_processes |o--o| project_iterations : "é a leitura de"
connected_tools ||--o{ sro_sprints : alcança
sro_sprints ||--o{ sro_sprint_issues : compromete
collected_issues ||--o{ sro_sprint_issues : "está em"
observed_projects {
uuid id PK
uuid tenant_id FK
uuid connected_tool_id FK
int number
string title
bool closed
string source_external_id
datetime no_longer_observed_at
}
project_items {
uuid id PK
uuid tenant_id FK
uuid observed_project_id FK
uuid collected_issue_id FK "nulo = rascunho"
bool is_draft
string source_external_id
datetime no_longer_observed_at
}
project_field_definitions {
uuid id PK
uuid tenant_id FK
uuid observed_project_id FK
string field_external_id
string name
string data_type
json_array options
datetime no_longer_observed_at
}
item_field_values {
uuid id PK
uuid tenant_id FK
uuid project_item_id FK
uuid project_field_definition_id FK
json raw_value
string interpreted_as
datetime last_observed_at
}
project_iterations {
uuid id PK
uuid tenant_id FK
uuid observed_project_id FK
string iteration_external_id
string field_external_id
string title
date start_date
int duration_days
uuid sro_sprint_id FK "XOR com spo_intended_process_id"
uuid spo_intended_process_id FK
datetime no_longer_in_configuration_at
}
smpo_iteration_field_roles {
uuid id PK
uuid tenant_id FK
uuid observed_project_id FK
string field_name
string role "sprint ou planning_horizon"
uuid declared_by_user_id FK
datetime declared_at
datetime revoked_at "nulo = vale"
}
sro_sprints {
uuid id PK
uuid tenant_id FK
string internal_id
uuid connected_tool_id FK
int board_number
string board_title
string field_name
string title
date started_on
int duration_days
date ended_on
bool completed
}
sro_sprint_issues {
uuid id PK
uuid tenant_id FK
uuid sprint_id FK
uuid collected_issue_id FK
datetime observed_at
datetime no_longer_observed_at
}
spo_intended_project_processes {
uuid id PK
uuid tenant_id FK
string internal_id
string title
date planned_start_on
int duration_days
datetime no_longer_observed_at
}
Os CHECK — quatro deles dizem "exatamente um"¶
| Constraint | Regra |
|---|---|
project_iterations_exatamente_um_destino |
sro_sprint_id ou spo_intended_process_id — exatamente um |
criterio_tem_um_alvo_so |
num_nonnulls(project_id, observed_project_id) = 1 |
prazo_tem_um_alvo_so |
(project_id IS NULL) <> (observed_project_id IS NULL) |
campo_so_quando_a_origem_e_campo |
(source = 'board_field') = (field_name IS NOT NULL) |
project_items_rascunho_sem_issue |
NOT (is_draft AND collected_issue_id IS NOT NULL) |
Os dois do meio são a mesma regra escrita de duas formas — num_nonnulls(...) = 1 e
(a IS NULL) <> (b IS NULL) —, em tabelas irmãs criadas em momentos diferentes. Equivalentes,
e vale registrar a inconsistência de estilo para quem for escrever a terceira.
campo_so_quando_a_origem_e_campo é uma equivalência, não uma implicação: proíbe
board_field sem campo e campo com origem diferente de board_field. Uma implicação
simples deixaria a segunda passar.
Os índices parciais¶
| Índice | Forma |
|---|---|
spo_projects_name_index |
UNIQUE (tenant_id, name) WHERE removed_at IS NULL |
spo_project_organizations_vigente_index |
UNIQUE (tenant_id, project_id, organization_id) WHERE unlinked_at IS NULL |
spo_project_teams_vigente_index |
UNIQUE (tenant_id, project_id, team_id) WHERE unlinked_at IS NULL |
spo_project_repositories_vigente_index |
UNIQUE (tenant_id, project_id, observed_repository_id) WHERE unlinked_at IS NULL |
spo_project_boards_vigente_index |
UNIQUE (tenant_id, project_id, observed_project_id) WHERE unlinked_at IS NULL |
spo_activity_start_criteria_projeto_vigente_index |
UNIQUE (tenant_id, project_id) WHERE revoked_at IS NULL AND project_id IS NOT NULL |
spo_activity_start_criteria_quadro_vigente_index |
UNIQUE (tenant_id, observed_project_id) WHERE revoked_at IS NULL AND observed_project_id IS NOT NULL |
spo_prazo_vigente_do_projeto_index |
UNIQUE (tenant_id, project_id, source, field_name) NULLS NOT DISTINCT WHERE revoked_at IS NULL AND project_id IS NOT NULL |
spo_prazo_vigente_do_quadro_index |
UNIQUE (tenant_id, observed_project_id, source, field_name) NULLS NOT DISTINCT WHERE revoked_at IS NULL AND observed_project_id IS NOT NULL |
smpo_papel_vigente_do_campo_index |
UNIQUE (tenant_id, observed_project_id, field_name) WHERE revoked_at IS NULL |
Dez índices parciais — o subsistema com mais invariantes do banco, e todos dizem a mesma coisa: um por alvo, enquanto vigente.
Os dois de prazo usam NULLS NOT DISTINCT, e é o detalhe que decide se a regra funciona:
sem ele, duas linhas com field_name nulo não colidiriam (no Postgres, NULL <> NULL), e
"um critério de prazo vigente" deixaria de significar exatamente onde mais importa — nas
origens sprint e milestone, que não têm campo.
Os critérios têm dois índices cada, um por alvo, em vez de um só sobre o par. Um índice
único sobre (tenant_id, project_id, observed_project_id) deixaria passar dois critérios para
o mesmo quadro, porque project_id nulo os tornaria distintos.
As FKs e o que acontece ao apagar¶
| De | Para | Ao apagar |
|---|---|---|
todos os spo_project_*.project_id |
spo_projects |
CASCADE |
spo_project_teams.team_id |
eo_teams |
CASCADE |
spo_project_organizations.organization_id |
eo_organizations |
CASCADE |
spo_project_repositories.observed_repository_id |
observed_repositories |
CASCADE |
spo_project_boards.observed_project_id |
observed_projects |
CASCADE |
spo_projects.parent_id |
spo_projects |
SET NULL — o filho vira projeto de topo |
spo_performed_project_activities.performer_id |
eo_people |
SET NULL |
spo_performed_project_activities.organization_id |
eo_organizations |
SET NULL |
project_items.collected_issue_id |
collected_issues |
SET NULL — o item do quadro fica |
project_iterations.sro_sprint_id / .spo_intended_process_id |
— | SET NULL |
item_field_values.* |
item e definição | CASCADE |
todos os *_by_user_id |
users |
SET NULL |
todos os .tenant_id |
tenants |
RESTRICT |
project_iterations com os dois SET NULL pode violar o próprio CHECK: apagar o sprint
apontado deixaria as duas colunas nulas, e project_iterations_exatamente_um_destino falharia
na próxima escrita da linha. Não é bug observado — sro_sprints é apagado junto com a
ferramenta conectada, e nesse caso o quadro e a iteração já foram embora. Registrado como
canto do modelo, para quem for apagar um sprint isoladamente.
O que o diagrama não mostra¶
inserted_at/updated_at,record_version,internal_id.source_system,source_instance,source_external_id,collected_at,last_observed_at— Application Reference nas tabelas coletadas.spo_projects.phasenão está aqui porque não é coluna: éEcto.Enumvirtual (project.ex:45).spo_project_*.unlinked_by_user_idespo_activity_*.revoked_by_user_id— só as datas estão no diagrama; a autoria está na tabela de FKs.- Os índices não parciais.
O que o dado diz hoje¶
Banco de desenvolvimento, 2026-09-12:
| Fato | Valor |
|---|---|
| projetos declarados | 1 |
| quadros observados | 15 |
| itens de quadro / valores de campo | 4 148 / 18 716 |
| iterações | 237 |
| sprints / issues em sprint | 205 / 2 559 |
| atividades realizadas | 30 560 |
| processos pretendidos | 34 |
| elos projeto↔equipe / ↔repositório | 2 / 16 |
| elos projeto↔organização, ↔quadro | 0 / 0 |
| critérios de início e de prazo | 0 / 0 |
| papéis de campo de iteração declarados | 0 |
Cinco tabelas do SPO e a do SMPO estão vazias. O modelo existe inteiro; o uso, quase nada.
E o detalhe que só a leitura do código explica: as 237 iterações têm destino — 205 apontam
sprint e 32 apontam processo pretendido, e o CHECK de destino único está satisfeito nas 237.
Mas nenhuma delas passou por smpo_iteration_field_roles, que está vazia.
A razão é que as duas declarações agem em momentos diferentes:
| Declaração | Quando age | Fonte |
|---|---|---|
| a rota sprint × processo pretendido | na escrita, e por um critério só: startDate já passou |
lib/the_band/projects/commands.ex:122-128 e :151-154 |
smpo_iteration_field_roles |
na leitura — filtra e interpreta o que já está gravado | smpo/field_roles.ex, spo/deadline_criterion.ex, work_items/person_work.ex:222 |
Quem olhar só o ERD conclui que a tabela vazia impede o roteamento, e não impede. Quem medir
"sprints da equipe" sem declarar o papel do campo, por outro lado, conta trimestre de
planejamento como sprint — que é exatamente o defeito que a issue #514 fechou
(smpo/field_roles.ex:4-7: os seis quadros com Quarter também têm Sprint).