# API Construflow — documentação de integração

> Última atualização: 2026-09-17 21:16:26 -03:00
> Texto cru para processamento por máquina: https://docs.construflow.com.br/api-para-integradores.md

Este documento é autossuficiente: ele leva você da emissão da credencial até uma cópia sincronizada
dos dados de obra, passando pelos limites de uso, pelos filtros de consulta, pela escrita e pela
importação de verificação de modelo. Tudo o que a sua integração precisa saber está aqui.

---

## 1. Abertura

### O que a API entrega

O Construflow é uma plataforma de gestão de obras. O objeto central é o **apontamento**: um problema,
uma tarefa ou uma verificação registrada num projeto, com disciplinas responsáveis, prazo, situação,
prioridade, local, etiquetas, imagens, posição no modelo 3D e uma conversa de comentários.

A API expõe esse acervo para leitura e escrita:

| O que você consegue fazer | Onde |
|---|---|
| Descobrir quais projetos a sua credencial enxerga | [§6](#6-descoberta) |
| Ler o cadastro de cada projeto — disciplinas, situações de disciplina, locais, etiquetas, categorias, fases | [§6](#6-descoberta) |
| Manter uma cópia sincronizada de apontamentos e comentários, inclusive exclusões | [§7](#7-sincronização-incremental) |
| Consultar com o vocabulário completo de filtros | [§10](#10-consultas-e-filtros) |
| Criar e alterar apontamentos e comentários, anexar imagens | [§11](#11-escrita) |
| Importar uma verificação de modelo em BCF | [§12](#12-importação-de-verificação-de-modelo-bcf) |

Os casos de uso que essa superfície atende bem: espelhar o acervo de apontamentos num ERP ou num
painel de BI; alimentar um agente de IA que acompanha a obra; publicar de volta o que o seu sistema
decidiu; e levar o resultado de uma verificação de modelo BIM para dentro do projeto.

O que ela **não** atende: notificação em tempo real. Não há canal de assinatura aberto a
integradores — o caminho é a sincronização incremental de [§7](#7-sincronização-incremental).

### Como a API é servida

- Uma superfície principal, GraphQL: **`POST /graphql`**. Uma operação por chamada HTTP. É por onde
  passa todo o acervo — projetos, apontamentos, comentários — e toda a escrita.
- Duas rotas REST, cada uma para o que não cabe no grafo: **`GET /identity/users/me`**, o cadastro de
  quem assina a credencial ([§2](#emitir-a-chave-e-o-segredo)), e
  **`POST /bcf-server/bcf/import-process/{projectId}`**, a importação de verificação de modelo
  ([§12](#12-importação-de-verificação-de-modelo-bcf)).
- Binário nunca trafega pela API: imagens sobem por URL assinada, direto no armazenamento
  ([§11](#imagens-em-três-passos)).

### Vocabulário

Este texto usa os termos de obra; a API usa os nomes em inglês. O mapa:

| Neste documento | Na API |
|---|---|
| apontamento | `issue` |
| comentário | `comment` |
| disciplina | `discipline` |
| local | `local` |
| etiqueta | `label` |
| categoria | `category` |
| fase | `phase` |
| situação | `status` |
| visibilidade | `visibility` |

### Quanto custa integrar

Uma integração de espelhamento completa são **três consultas**, executadas em laço por projeto, mais
uma consulta de descoberta ([§6](#os-projetos-que-a-credencial-enxerga)) e o cadastro de cada projeto
([§6](#o-cadastro-do-projeto)), relido de tempos em tempos e não a cada rodada. Escrever as chamadas
é a parte barata.

O que consome o tempo de quem integra é tratar corretamente cinco coisas, e todas estão documentadas
aqui:

1. Paginação e cursor — inclusive um falso positivo na sinalização de próxima página
   ([§10](#paginação-e-cursor)).
2. A janela de data — as bordas são inclusivas e o início errado perde edições
   ([§7](#a-janela-de-data)).
3. Exclusões — duas fontes independentes, e um conjunto de mudanças que **não** produz sinal nenhum
   ([§9](#9-exclusões-e-reconciliação)).
4. Os limites de uso e o contrato de recusa ([§5](#5-limites-de-uso)).
5. As regras da alteração parcial na escrita, onde lista vazia apaga em uns campos e é recusada em
   outros ([§11](#alteração-parcial-as-cinco-regras)).

Leia [§4](#4-erros-e-recusas), [§5](#5-limites-de-uso), [§7](#7-sincronização-incremental) e
[§9](#9-exclusões-e-reconciliação) **antes** de decidir a arquitetura da sua rotina. Os três últimos
mudam o desenho, não o detalhe.

### Índice

- [API Construflow — documentação de integração](#)
  - [1. Abertura](#1-abertura)
    - [O que a API entrega](#o-que-a-api-entrega)
    - [Como a API é servida](#como-a-api-é-servida)
    - [Vocabulário](#vocabulário)
    - [Quanto custa integrar](#quanto-custa-integrar)
    - [Índice](#índice)
  - [2. Autenticação](#2-autenticação)
    - [Emitir a chave e o segredo](#emitir-a-chave-e-o-segredo)
    - [O que a credencial enxerga](#o-que-a-credencial-enxerga)
    - [Quando a credencial não é aceita](#quando-a-credencial-não-é-aceita)
  - [3. Endereço e rotas](#3-endereço-e-rotas)
  - [4. Erros e recusas](#4-erros-e-recusas)
    - [As três formas de recusa](#as-três-formas-de-recusa)
    - [A quarta forma: recusa na borda](#a-quarta-forma-recusa-na-borda)
    - [O que pode ser retentado](#o-que-pode-ser-retentado)
  - [5. Limites de uso](#5-limites-de-uso)
    - [A tabela de limites](#a-tabela-de-limites)
    - [Como os limites se combinam](#como-os-limites-se-combinam)
    - [Forma e custo da consulta](#forma-e-custo-da-consulta)
    - [O envelope de recusa por taxa](#o-envelope-de-recusa-por-taxa)
    - [Dimensionamento](#dimensionamento)
  - [6. Descoberta](#6-descoberta)
    - [Os projetos que a credencial enxerga](#os-projetos-que-a-credencial-enxerga)
    - [O cadastro do projeto](#o-cadastro-do-projeto)
      - [Catálogo do projeto × catálogo global](#catálogo-do-projeto--catálogo-global)
      - [A forma de `locals` e `labels`](#a-forma-de-locals-e-labels)
      - [Grupos](#grupos)
      - [Frequência](#frequência)
  - [7. Sincronização incremental](#7-sincronização-incremental)
    - [Por que são duas fontes, e não uma](#por-que-são-duas-fontes-e-não-uma)
    - [A janela de data](#a-janela-de-data)
    - [Passo 1 — apontamentos alterados](#passo-1--apontamentos-alterados)
    - [Passo 2 — comentários alterados](#passo-2--comentários-alterados)
    - [Passo 3 — detalhe da união](#passo-3--detalhe-da-união)
      - [Buscar o detalhe em lotes](#buscar-o-detalhe-em-lotes)
    - [Carga inicial](#carga-inicial)
    - [Custo por rodada](#custo-por-rodada)
  - [8. Seleção de campos](#8-seleção-de-campos)
    - [Campos a evitar, e por quê](#campos-a-evitar-e-por-quê)
    - [`viewpoints` e `bimPins` — só no fluxo BIM](#viewpoints-e-bimpins--só-no-fluxo-bim)
    - [Deixe a consulta resolver os nomes](#deixe-a-consulta-resolver-os-nomes)
    - [Rótulos que a API não traduz](#rótulos-que-a-api-não-traduz)
    - [Imagens](#imagens)
  - [9. Exclusões e reconciliação](#9-exclusões-e-reconciliação)
    - [O que você detecta](#o-que-você-detecta)
    - [O que você NÃO detecta](#o-que-você-não-detecta)
    - [A recarga total periódica](#a-recarga-total-periódica)
  - [10. Consultas e filtros](#10-consultas-e-filtros)
    - [Listagem de apontamentos](#listagem-de-apontamentos)
    - [Paginação e cursor](#paginação-e-cursor)
    - [O objeto `filter`](#o-objeto-filter)
      - [`filter.standard`](#filterstandard)
      - [`filter.code`](#filtercode)
      - [`filter.ids` — o filtro estrito](#filterids--o-filtro-estrito)
      - [`filter.disciplineIds`, `filter.labelIds`, `filter.localIds`](#filterdisciplineids-filterlabelids-filterlocalids)
      - [`filter.text`](#filtertext)
      - [`filter.updatedAtFrom` / `filter.updatedAtTo`](#filterupdatedatfrom--filterupdatedatto)
      - [`filter.includeDeleted` — pedir os excluídos](#filterincludedeleted--pedir-os-excluídos)
    - [Erros de formato](#erros-de-formato)
    - [Exemplos](#exemplos)
    - [Detalhe de um apontamento](#detalhe-de-um-apontamento)
    - [Comentários de um apontamento](#comentários-de-um-apontamento)
    - [Comentários do projeto](#comentários-do-projeto)
  - [11. Escrita](#11-escrita)
    - [Criar apontamento](#criar-apontamento)
    - [Alterar apontamento](#alterar-apontamento)
    - [Alteração parcial: as cinco regras](#alteração-parcial-as-cinco-regras)
    - [Escrita em lote](#escrita-em-lote)
      - [`updateIssues` é uma operação **parcial** — leia o retorno](#updateissues-é-uma-operação-parcial--leia-o-retorno)
      - [O input do lote não é o mesmo da alteração individual](#o-input-do-lote-não-é-o-mesmo-da-alteração-individual)
      - [`updateIssuesDisciplinesStatus` — mover a situação das disciplinas](#updateissuesdisciplinesstatus--mover-a-situação-das-disciplinas)
      - [Criação em lote e cópia — as duas restrições que surpreendem](#criação-em-lote-e-cópia--as-duas-restrições-que-surpreendem)
      - [Duas observações que evitam desenho errado](#duas-observações-que-evitam-desenho-errado)
    - [Não atropelar quem está editando](#não-atropelar-quem-está-editando)
    - [Criar e editar comentário](#criar-e-editar-comentário)
    - [Imagens em três passos](#imagens-em-três-passos)
  - [12. Importação de verificação de modelo (BCF)](#12-importação-de-verificação-de-modelo-bcf)
    - [Passo 1 — abrir o processo de importação](#passo-1--abrir-o-processo-de-importação)
    - [Passo 2 — subir o arquivo](#passo-2--subir-o-arquivo)
    - [Passo 3 — o resultado](#passo-3--o-resultado)
    - [Comportamento da importação](#comportamento-da-importação)
      - [Viewpoints não são sobrescritos](#viewpoints-não-são-sobrescritos)
    - [Como os campos do arquivo são interpretados](#como-os-campos-do-arquivo-são-interpretados)
    - [Armadilhas da importação](#armadilhas-da-importação)
  - [13. Checklist de integração](#13-checklist-de-integração)
    - [Antes da primeira chamada](#antes-da-primeira-chamada)
    - [Na carga inicial](#na-carga-inicial)
    - [Em cada rodada](#em-cada-rodada)
    - [Periodicamente](#periodicamente)
    - [Ao escrever](#ao-escrever)
    - [Ao importar BCF](#ao-importar-bcf)

---

## 2. Autenticação

A API usa **HTTP Basic Auth** com uma Chave de Acesso. Toda requisição leva o cabeçalho:

```
Authorization: Basic base64(<chave>:<segredo>)
```

### Emitir a chave e o segredo

Você emite a sua credencial sozinho, pela interface do Construflow. Não há aprovação, papel especial
nem módulo contratado envolvido:

1. Clique no **avatar do usuário**, no canto da tela, e escolha **Configurações**.
2. No **menu lateral**, escolha **Chaves de acesso**.
3. Selecione **Criar** para gerar a chave e o segredo.
4. Copie os dois. **O segredo é exibido uma única vez, no momento da criação** — depois não há como
   recuperá-lo, só emitir outra chave.

⚠️ **Conta demonstrativa não emite chave.** A criação é recusada para o usuário de demonstração. Para
integrar é preciso um usuário comum da plataforma.

Teste imediatamente, na rota que responde de quem é a credencial:

```bash
curl https://api.construflow.com.br/identity/users/me \
  -u "$CONSTRUFLOW_KEY:$CONSTRUFLOW_SECRET" \
  -H "User-Agent: MinhaIntegracao/1.0 (contato@empresa.com)"
```

```json
{
  "id": 3367,
  "guid": "9f1c…",
  "name": "Integração",
  "lastName": "Empresa",
  "email": "integracao@empresa.com.br",
  "photo": null,
  "isAdmin": false,
  "isBeta": false,
  "isDemo": false,
  "acceptedPolicies": true,
  "confirmedRegister": "2025-04-02T13:20:11.000Z",
  "belongsToAccount": true
}
```

Se o cadastro veio, está autenticado. Se a resposta for `401`, leia
[Quando a credencial não é aceita](#quando-a-credencial-não-é-aceita).

Esta é a chamada mais barata da API — ela não toca em projeto nenhum —, e é a certa para o
**healthcheck** da sua integração e para a tela de configuração dela ("chave conectada como
Integração Empresa"). Ela responde **quem** é a credencial; **o que** ela alcança vem da lista de
projetos ([§6](#os-projetos-que-a-credencial-enxerga)).

Todas as operações deste documento assumem que a requisição já leva o cabeçalho de autenticação.

### O que a credencial enxerga

A chave **é** o usuário que a emitiu. Ela não tem escopo próprio:

| Característica | Comportamento |
|---|---|
| Acesso | Exatamente o do usuário dono: os projetos dele, a visibilidade dele, as permissões dele em cada projeto |
| Restrição por projeto | Não existe. Não há como emitir uma chave limitada a um subconjunto de projetos |
| Restrição a somente-leitura | Não existe. A mesma chave lê e escreve |
| Validade | **Não expira.** Não há data de vencimento nem rotação automática |
| Revogação | Manual, na mesma tela em que a chave foi criada |
| Quantidade | Sem limite de chaves por usuário |

A consequência prática de "a chave é o usuário": **emita a credencial a partir de um usuário criado
para a integração**, com as permissões de projeto que a integração precisa e nada além. Uma chave
emitida por um coordenador enxerga tudo que aquele coordenador enxerga, inclusive apontamentos
restritos que a integração não deveria receber.

As permissões relevantes, por projeto:

| Para | É preciso ter no projeto |
|---|---|
| Ler apontamentos, comentários e cadastro | permissão de visualizar |
| Criar/alterar apontamento e comentário, pedir URL de upload | permissão de participar |
| Alterar o cadastro do projeto | coordenação ou administração |

⚠️ **A revogação não é instantânea.** Uma chave revogada pode continuar sendo aceita por **até 10
minutos**. Se você precisa cortar o acesso agora — vazamento de segredo, desligamento de um
fornecedor — conte com essa janela e trate o que acontecer nela.

⚠️ **A cota de uso é por identidade, não por chave.** Criar uma segunda Chave de Acesso para o mesmo
usuário **não** dobra nada: as duas somam no mesmo balde ([§5](#5-limites-de-uso)). Para separar
cotas é preciso separar usuários.

**A identidade é derivada da credencial pelo servidor.** Não existe parâmetro de usuário em nenhuma
operação, e cabeçalhos de identidade enviados por você são **descartados na entrada** — não há como
uma integração agir em nome de outra pessoa.

### Quando a credencial não é aceita

**Nas rotas REST**, a recusa é direta: `401`, com `WWW-Authenticate: Bearer` e o corpo

```json
{ "code": "UNAUTHENTICATED", "message": "User not authenticated" }
```

**No `POST /graphql`, não.** A requisição segue, a identidade simplesmente não é resolvida, e a
operação falha na autorização:

```json
{
  "errors": [
    {
      "message": "User not authenticated",
      "extensions": { "code": "UNAUTHENTICATED" }
    }
  ]
}
```

⚠️ O status dessa resposta é **`200`**, não `401` — a porta do GraphQL admite operações
legitimamente anônimas, então ela não recusa por falta de identidade. É por isso que o teste de
credencial acima usa a rota REST: lá o desfecho está no status.

Em qualquer das duas, chave revogada, segredo errado e cabeçalho ausente produzem **o mesmo
desfecho** — não há como distinguir os três pela resposta. Se a sua integração parou de autenticar,
verifique nesta ordem: o cabeçalho está sendo enviado; o base64 está correto; a chave não foi
revogada.

---

## 3. Endereço e rotas

O host é `https://api.construflow.com.br`, e sobre ele existem duas rotas:

| Rota | Para quê |
|---|---|
| `POST /graphql` | Todas as consultas e alterações |
| `GET /identity/users/me` | O cadastro de quem assina a credencial ([§2](#emitir-a-chave-e-o-segredo)) |
| `POST /bcf-server/bcf/import-process/{projectId}` | Abrir importação de BCF ([§12](#12-importação-de-verificação-de-modelo-bcf)) |

**O esquema GraphQL não é navegável.** Introspecção (`__schema`, `__type`) é recusada. Este documento
é a referência. `__typename` continua funcionando normalmente.

---

## 4. Erros e recusas

Antes de escrever a primeira consulta, entenda como uma recusa chega até você. Integração que
confunde as famílias abaixo faz retry do que nunca vai mudar e desiste do que só precisava esperar.

### As três formas de recusa

É o **status HTTP** que as separa. Valem para o `POST /graphql`, por onde passa quase tudo; as rotas
REST recusam credencial com `401` ([§2](#quando-a-credencial-não-é-aceita)):

| Situação | Status | Onde está o detalhe |
|---|---|---|
| Erro de negócio, de permissão, de autenticação **ou de formato de filtro** | `200` | `errors[0].extensions.code` e `errors[0].message` |
| Consulta acima de um teto de **forma/custo**, ou array de operações | `400` | `errors[0].extensions` |
| Excesso de **taxa**, em qualquer balde | `429` | cabeçalhos `RateLimit*` + corpo |

⚠️ **`200` não significa sucesso.** Erro de permissão, de autenticação e de formato de filtro chegam
com `200` e a lista `errors` preenchida — é o comportamento normal do GraphQL, e é a causa mais comum
de integração que grava dados vazios sem perceber. **Sempre** verifique `errors` antes de ler `data`.

⚠️ **Recusa `400` não tem prazo de espera.** Ela não traz `Retry-After` nem `retryAfter`, e isso é
deliberado: repetir a mesma consulta produz a mesma recusa para sempre. Corrija a consulta — não
faça retry.

Os códigos de `400` são quatro, e todos são sobre a **forma** da chamada, nunca sobre o conteúdo de
um filtro:

| `extensions.code` | O que aconteceu | `extensions` traz | Onde está explicado |
|---|---|---|---|
| `QUERY_DEPTH_EXCEEDED` | A consulta passou de 10 níveis de profundidade | `code`, `reason`, `limit`, `observed` | [§5](#forma-e-custo-da-consulta) |
| `PAGE_SIZE_EXCEEDED` | `first` acima de 200 | `code`, `reason`, `limit`, `observed` | [§5](#forma-e-custo-da-consulta) |
| `QUERY_COST_EXCEEDED` | A consulta instancia objetos demais | `code`, `reason`, `limit`, `observed` | [§5](#forma-e-custo-da-consulta) |
| `BATCHING_NOT_SUPPORTED` | Você enviou um array de operações numa chamada só | **só `code`** | [§5](#forma-e-custo-da-consulta) |

A ordem de avaliação é fixa — profundidade, depois tamanho de página, depois preço. Uma consulta que
viola dois tetos é sempre recusada pelo primeiro.

⚠️ **As recusas por formato de filtro NÃO são `400`.** Cursor de paginação fora de formato
(`INVALID_PAGINATION_CURSOR`) e `filter.ids` com item inválido (`INVALID_ISSUES_IDS_FILTER`) chegam
com status **`200`**, o campo consultado nulo e o erro em `errors[0]`. É contraintuitivo — parecem
requisição malformada, e não são tratadas como tal. Detalhe em [§10](#erros-de-formato).

### A quarta forma: recusa na borda

⚠️ **Corpo grande demais não devolve `429` nem `413` — devolve `403`.** O teto de 2 MB por requisição
é aplicado na borda da rede, antes de a requisição chegar à aplicação. A resposta não é GraphQL: não
tem `errors`, não tem `extensions`, não tem cabeçalho de limite. Isso contradiz a intuição e é a
recusa mais difícil de diagnosticar — se você recebeu um `403` seco onde esperava um erro de API,
meça o tamanho do corpo que enviou.

Binário não deve ir embutido na requisição. Use a URL assinada
([§11](#imagens-em-três-passos)) e faça o `PUT` direto no armazenamento.

### O que pode ser retentado

| Status | Retry? | Como |
|---|---|---|
| `200` com `errors` | Não | É erro de negócio, permissão ou autenticação. Corrija a chamada ou a permissão |
| `400` | **Nunca** | A resposta não muda. Corrija a consulta |
| `403` | Não | Reduza o corpo |
| `429` | Sim | Espere exatamente o que o `Retry-After` mandar ([§5](#o-envelope-de-recusa-por-taxa)) |
| `5xx` | Sim | Backoff exponencial `2^n` segundos, com jitter. Nunca em laço imediato |

---

## 5. Limites de uso

> **A política é não bloquear.** Não existe suspensão automática, bloqueio automático nem revogação
> automática de chave. Exceder um limite recusa **aquela** requisição, e nada mais: a requisição
> seguinte, dentro da faixa, passa normalmente. Se um padrão de uso passa a degradar a plataforma,
> nós procuramos o responsável pela conta; a revogação de uma Chave de Acesso, se chegar a esse
> ponto, é sempre uma **ação manual** tomada depois desse contato.
>
> Isso não faz dos limites uma sugestão. A capacidade é compartilhada entre todos os clientes, e uma
> integração que trabalha acima da faixa **produz falha primeiro para si mesma**: a maior parte das
> recusas cai sobre quem está causando a rajada, na forma de rodadas incompletas e dados faltando na
> sua base — descobertos dias depois, quando alguém nota o buraco. Dimensionar dentro da faixa é,
> antes de tudo, o que torna a sua integração previsível.

### A tabela de limites

| Limite | Valor | Ao exceder |
|---|---|---|
| **Volume total por identidade** (portaria: **toda** requisição que passa pelo gateway) | **160 / 10 s** e **420 / 60 s** | `429` `user-envelope` |
| Consultas por identidade | **120 / 10 s** e **300 / 60 s** | `429` `user-read` |
| Mutations por identidade | **40 / 10 s** e **120 / 60 s** | `429` `user-write` |
| Emissão de URL assinada por identidade (balde próprio) | **40 / 10 s** e **120 / 60 s** | `429` `user-upload-url` |
| Requisições simultâneas por identidade | **8 em voo** | `429` `credential-concurrency` |
| Requisições por endereço de origem | **6.000 / 60 s** | `429` |
| Operações por chamada HTTP | **1** | `400` `BATCHING_NOT_SUPPORTED` |
| Profundidade da consulta | **10 níveis** | `400` `QUERY_DEPTH_EXCEEDED` |
| Tamanho de página (`first`) | **200** | `400` `PAGE_SIZE_EXCEEDED` |
| Preço da operação | por objeto instanciado — ver abaixo | `400` `QUERY_COST_EXCEEDED` |
| Corpo em `application/json` | **2 MB** | `403` na borda |
| Tempo de cada consulta ao banco | **30 s** | `200` com erro genérico — ver abaixo |

⚠️ **Os 30 s são por consulta ao banco, não por operação.** Uma operação que dispare muitas consultas
curtas pode levar bem mais de 30 s no total sem que nenhuma seja cortada; o que estoura é a consulta
**individual** que passa disso. Quando estoura, você recebe **`200` com a lista `errors`
preenchida**, um erro genérico e **sem código dedicado** — não há `code` que identifique "tempo
esgotado". A pista é a mensagem, não o código. Do lado do cliente, o remédio é reduzir a página e a
seleção, não fazer retry.

**Não há limite diário.** As janelas são de 10 e de 60 segundos. Não existe cota mensal, nem número
máximo de requisições por dia — o que existe é ritmo.

### Como os limites se combinam

**As duas janelas coexistem.** A de 10 segundos (rajada) e a de 60 segundos (sustentado) valem ao
mesmo tempo, e basta uma estourar para a requisição ser recusada. A janela curta existe porque a
longa, sozinha, admitiria a cota inteira nos dois primeiros segundos do minuto — que é exatamente a
forma de tráfego que derruba o serviço.

**A portaria recusa primeiro — e o motivo não é o número, é o que ela conta.** Os números da portaria
empatam com a soma dos baldes finos (120+40 = 160, 300+120 = 420), então parece que ela nunca chegaria
antes. Chega, porque **ela conta toda requisição autenticada que atravessa o gateway**, sem distinguir
método, rota nem tipo de operação: consultas, alterações, emissão de URL assinada, importação de BCF
e qualquer chamada a outro serviço da plataforma. Os baldes finos orçam só as operações GraphQL de
leitura e de escrita; a portaria orça o seu tráfego inteiro.

O exemplo mais direto: 120 consultas + 40 alterações + 40 emissões de URL assinada em 10 segundos.
**Nenhum** balde fino estourou — cada um está exatamente no teto — e a portaria recusa na 161ª
requisição. Quando isso acontece, a razão devolvida é `user-envelope`, não `user-read` nem
`user-write`, e ela vem com o corpo plano ([o envelope de recusa](#o-envelope-de-recusa-por-taxa)).

Ao dimensionar, conte **todas** as suas requisições contra 160/10 s e 420/60 s — não só as que você
classificaria como consulta ou como escrita.

**A cota é por identidade.** Não por chave (duas chaves do mesmo usuário somam no mesmo balde), não
por empresa, e não por endereço de origem. Distribuir a carga entre várias máquinas **não** aumenta o
que a credencial pode fazer — o limite por IP é uma proteção adicional, não uma cota que se
multiplica.

**Emissão de URL assinada tem balde próprio.** Ela não consome o balde de mutations, mas consome a
portaria. Emitir a URL é barato — o binário não passa pela API. Peça as URLs do gesto inteiro numa
chamada só (até 50 nomes), sob demanda: N chamadas paralelas é o que estoura este balde.

### Forma e custo da consulta

**Uma operação por chamada HTTP.** Enviar um **array** de operações num único `POST /graphql`
responde `400` com `BATCHING_NOT_SUPPORTED` e **nada é executado** — inclusive quando o array está
vazio. Se o seu cliente GraphQL agrupa automaticamente (`BatchHttpLink` e equivalentes), **desligue
isso**. Não confunda com as mutations em lote (`createIssues`, `updateIssues`, `copyProjectIssues`),
que continuam sendo a forma recomendada de escrever muitos apontamentos: elas são **uma** operação
com muitos itens ([§11](#escrita-em-lote)).

**Profundidade máxima de 10 níveis.** Cada nível de seleção conta, **incluindo o nível do campo
escalar final**. Em `project → issues → issues → userPersonalData → tags → tag → id` a profundidade é
**7**. Fragmentos são medidos onde foram espalhados e não acrescentam nível por si. A recusa acontece
**antes** de qualquer acesso ao banco, então uma consulta profunda demais é barata para nós e inútil
para você.

**Página máxima de 200.** Passe `first` sempre de forma explícita, em toda consulta paginada — e é
justamente a omissão que o teto **não** protege. Quando você não informa o tamanho da página, a
consulta é avaliada como se fosse **100**, passa folgada pelo teto de 200 — e o servidor devolve até
**500**, que é o padrão real das duas consultas paginadas. Você recebe cinco vezes a página que a
avaliação considerou, sem nenhum sinal. Uma página de 500 itens é mais cara e mais lenta do que
qualquer página que você conseguiria pedir de propósito, e é por isso que informar o valor é
obrigatório, não recomendado.

**Preço da operação.** O custo é cobrado por **objeto instanciado**, não por campo pedido:

- Campo escalar folha (`id`, `title`, `updatedAt`, `createdByUserId`) custa **zero**.
- O que multiplica é **o tamanho da página vezes o número de objetos por item**. Cada
  `disciplines { discipline { ... } }`, `locals { local { ... } }`, `images { ... }`,
  `createdByUser { ... }` é um objeto por item.
- Uma listagem de apontamentos com os relacionamentos expandidos custa da ordem de **15 objetos por
  apontamento**. Com `first: 50`, são 750 objetos numa chamada — dentro da faixa. O mesmo pedido com
  `first: 200` seria recusado com `QUERY_COST_EXCEEDED`.

⚠️ **A regra prática de tamanho de página, e ela é a que evita a recusa:**

| A sua seleção | `first` |
|---|---|
| Tem relacionamentos expandidos (`disciplines { discipline { … } }`, `locals { local { … } }`, `labels { … }`, `images { … }`, `createdByUser { … }`) | **da ordem de 50** |
| Só campos escalares (`id`, `code`, `title`, `status`, `updatedAt`, `createdByUserId`, `deletedAt`…) | até **200** |

Peça menos campos **e** ajuste a página ao que pediu. Numa varredura, trocar `createdByUser { name }`
por `createdByUserId` elimina um objeto por item — o nome vem depois, aninhado, na consulta de
detalhe, que traz um apontamento por vez ([§8](#deixe-a-consulta-resolver-os-nomes)).

### O envelope de recusa por taxa

Toda recusa por taxa traz o status `429` e os mesmos sete cabeçalhos. O **corpo**, porém, vem em duas
formas. A mais comum é o envelope GraphQL:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Policy: "burst";q=120;w=10, "sustained";q=300;w=60
RateLimit: "burst";r=0;t=3, "sustained";r=0;t=12
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785000060
X-RateLimit-Reason: user-read
Content-Type: application/json; charset=utf-8
```

```json
{
  "errors": [
    {
      "message": "You have reached the request rate limit",
      "extensions": {
        "code": "REQUEST_RATE_EXCEEDED",
        "reason": "user-read",
        "retryAfter": 12,
        "policy": "\"burst\";q=120;w=10, \"sustained\";q=300;w=60"
      }
    }
  ]
}
```

Como ler:

- **`Retry-After`** é em **segundos** e **tem precedência sobre tudo o mais**. Se você só for tratar
  um campo, trate este. Não invente o seu próprio intervalo a partir das janelas anunciadas: quem
  sabe quanto falta é o servidor.
- **`RateLimit-Policy`** anuncia as políticas **simultâneas** do balde que recusou — `q` = cota,
  `w` = janela em segundos. **`RateLimit`** dá o estado atual das mesmas políticas — `r` = restante,
  `t` = segundos até a virada.
- **`X-RateLimit-Limit` / `-Remaining` / `-Reset`** descrevem **só** a política que efetivamente
  recusou. ⚠️ `-Reset` é **época Unix em segundos** — base de tempo diferente do `t=` do `RateLimit`,
  que é um delta. Não são o mesmo número escrito de dois jeitos, e somar um ao relógio local dá um
  instante no ano de 1970 ou daqui a 56 anos, conforme o que você confundir.
- **`X-RateLimit-Reason`** diz **qual** balde estourou. É o que permite ajustar o lado certo sem
  adivinhação: recusa por `user-write` não se resolve reduzindo leitura.
- Os mesmos campos vêm no corpo (`reason`, `retryAfter`, `policy`) porque a leitura de cabeçalho
  depende de CORS: numa integração de servidor o corpo sempre chega.
  ⏳ *Escopado à `desbur/main` — NÃO vale na `main` ainda:* **a API não aceita chamada feita pelo
  navegador a partir de um site que não seja o aplicativo do Construflow.** Uma requisição que chega
  com origem de navegador não autorizada recebe `403` `{"error":"origin_not_allowed"}` antes de
  qualquer processamento, e o navegador não expõe nem cabeçalhos nem corpo. Integração roda no
  servidor, nunca no navegador.

**O vocabulário de `X-RateLimit-Reason` é fechado.** Qualquer valor fora desta lista é defeito nosso,
não caso novo a tratar:

| Razão | O que estourou |
|---|---|
| `user-envelope` | volume total da sua identidade, contado na portaria |
| `user-read` | volume de **consultas** |
| `user-write` | volume de **mutations** |
| `user-upload-url` | volume de **emissão de URL assinada** |
| `credential-concurrency` | trabalho **simultâneo** da sua identidade |

⚠️ **Nem toda recusa por taxa traz a lista `errors`.** As recusas aplicadas **antes** de a requisição
chegar ao GraphQL vêm com **corpo plano**, com os mesmos cinco campos na raiz. O status e os sete
cabeçalhos são idênticos; só a forma do corpo muda:

```json
{
  "message": "You have reached the request rate limit",
  "code": "REQUEST_RATE_EXCEEDED",
  "reason": "credential-concurrency",
  "retryAfter": 1,
  "policy": "\"concurrency\";q=8"
}
```

Qual forma vem em cada caso:

| Razão | Corpo |
|---|---|
| `user-envelope` (portaria) | **plano** |
| `credential-concurrency` | **plano** |
| `user-read`, `user-write`, `user-upload-url` | envelope GraphQL, com `errors[0].extensions` |

Repare que **é a portaria — o balde que recusa primeiro — que vem no formato plano.** A recusa mais
provável de todas é justamente a que não tem `errors`.

E há um caso em que não vem **nada**: a recusa por corpo grande demais acontece na **borda da rede**
([§4](#a-quarta-forma-recusa-na-borda)) e responde `403` sem nenhum dos sete cabeçalhos e sem corpo
JSON nosso.

**Trate `429` pelo status e pelos cabeçalhos, nunca pela forma do corpo.** Um cliente que procura
`errors[0].extensions.code` para decidir se houve recusa por taxa não enxerga as recusas planas e
entra em laço.

O teto de simultâneas conta **requisições em voo**, não por minuto. Ele existe porque saturação chega
em rajadas de décimos de segundo, invisíveis a qualquer contagem por minuto. Mantenha no máximo 4
requisições abertas ao mesmo tempo e você nunca o encontra.

### Dimensionamento

**Dimensione pela taxa, e mantenha o paralelismo baixo.** O balde que manda numa rodada de
sincronização é o de **consultas**: 300 por minuto, ou **5 por segundo** — a rodada é integralmente
de leitura, e a portaria (420/min) nunca chega a ser o gargalo. Um limitador global de
**~4,5 requisições por segundo** para toda a integração deixa margem para a variação de tempo de
resposta e mantém você dentro da faixa. Se a sua integração também escreve, o balde de mutations é
separado e menor (120/min) — dimensione a escrita à parte.

Na prática isso são **2 a 4 processos** (ou conexões) trabalhando em paralelo, nunca mais: com quatro
requisições em voo você já satura a taxa recomendada, bem antes de encostar no teto de simultâneas.
Se as respostas ficarem lentas, **reduza a taxa** — não abra mais conexões.

Paralelismo alto não acelera nada aqui: acima do ritmo do balde, o que você ganha em disparo perde em
recusa, e a rajada é justamente o padrão que degrada o tempo de resposta — inclusive o seu.

O resto do bom comportamento cabe em cinco linhas:

1. **Sincronize incrementalmente, não em laço.** Não repita a mesma consulta em intervalo curto para
   "ver se mudou" — use a janela de data ([§7](#7-sincronização-incremental)). Se precisar de
   verificação periódica, espace-a em **minutos**, não em segundos.
2. **Sem fan-out.** Não dispare N consultas em paralelo a partir de uma lista recém-recebida.
3. **Peça só os campos que vai usar.** É o que forma o preço da operação.
4. **Guarde o cadastro do projeto no seu lado.** Disciplinas, categorias, fases, etiquetas e locais
   mudam raramente: releia-os **periodicamente** — uma vez por dia é folgado —, não a cada rodada
   ([§6](#6-descoberta)).
5. **Identifique-se.** Envie sempre um `User-Agent` descritivo —
   `MinhaIntegracao/1.2 (contato@empresa.com)`. É por ele que conseguimos falar com você antes de
   qualquer revogação, em vez de descobrir um endereço anônimo gerando carga.

---

## 6. Descoberta

Duas consultas dão todo o contexto de que a sincronização precisa: a **lista de projetos**, barata e
executada a cada rodada, e o **cadastro de cada projeto**, relido periodicamente.

### Os projetos que a credencial enxerga

**Não existe uma query `projects` de raiz.** A lista vem aninhada no usuário logado, e é o ponto de
partida de toda integração — não há como listar apontamentos, comentários ou documentos fora de um
projeto.

```graphql
query AccessibleProjects {
  loggedUserData {
    projects(permission: "view") {
      id
      guid
      name
      status
      account { id name }
    }
  }
}
```

🔴 **Passe `permission: "view"` — o padrão não é o que você quer.** Sem o argumento, a lista traz só
os projetos em que o usuário tem permissão de **participar**, isto é, de escrever. Uma credencial
provisionada apenas para leitura — que é o que este documento recomenda
([§2](#o-que-a-credencial-enxerga)) — recebe **lista vazia**, embora leia todos os projetos
normalmente por `project(projectId)`. As duas permissões são independentes: ter uma não implica a
outra. Como todo papel de projeto concede as duas, `view` devolve um superconjunto de
`participate` — é sempre o valor certo para descobrir o que se pode **ler**.

⚠️ **O argumento é texto livre, sem validação.** O vocabulário é `participate`, `view`, `manage` e
`invites`; qualquer outro valor — inclusive um erro de digitação — devolve **lista vazia, sem erro**.
Se a descoberta voltou vazia, confira a grafia antes de suspeitar de permissão.

⚠️ **A lista inclui projeto inativo.** O campo `status` é o que distingue: sincronizar tudo que vem
na lista significa varrer projetos encerrados a cada rodada, o que multiplica o custo sem trazer
dado novo. Filtre pelo `status` e decida conscientemente se projeto inativo entra na sua rodada — em
geral vale sincronizar uma última vez e depois parar.

⚠️ **Projeto de acesso público em que a credencial não é membro não aparece aqui — com nenhum valor
de `permission`.** Nesses projetos a permissão de visualizar é concedida no momento da consulta e
nunca fica gravada, e esta lista sai do que está gravado. O projeto é perfeitamente consultável por
`project(projectId)` se você souber o identificador — só não há como descobri-lo por aqui, e não há
consulta que os enumere.

Projeto inativo **não** muda nada nas consultas de apontamento: os apontamentos continuam
respondendo normalmente.

⚠️ **Esta lista é o recorte da sua credencial, e ela encolhe em silêncio.** Se o usuário dono perder
acesso a um projeto, o projeto some daqui sem nenhum aviso e sem nenhum erro. Guarde o conjunto de
identificadores de uma rodada para a outra e compare: projeto que sumiu é o sinal
([§9](#9-exclusões-e-reconciliação)).

### O cadastro do projeto

Todo o cadastro vem numa chamada só. **Não existem queries dedicadas** para essas coleções — são
campos aninhados em `project(projectId)`:

```graphql
query ProjectCatalog($projectId: Int!) {
  project(projectId: $projectId) {
    id
    guid
    name

    disciplines { id guid name abbreviation }
    disciplinesStatus { name active }

    locals
    labels

    categories { id name active }
    phases { id key abbreviation name active }

    groups { id name }
  }
}
```

| Coleção | O que é | Formato |
|---|---|---|
| `disciplines` | Disciplinas **do projeto** (arquitetura, estrutura, hidráulica…) | Tipo forte |
| `locals` | Árvore de locais **do projeto** (torre, pavimento, sala…) | **`JSON` livre** |
| `labels` | Árvore de etiquetas **do projeto** | **`JSON` livre** |
| `categories` | Catálogo **global** de categorias, anotado com `active` — ver abaixo | Tipo forte |
| `phases` | Catálogo **global** de fases, anotado com `active` — ver abaixo | Tipo forte |
| `disciplinesStatus` | Catálogo **global** de situações de disciplina, anotado com `active` | Tipo forte |
| `groups` | Grupos do projeto de que o usuário é membro — ver abaixo | Tipo forte |

#### Catálogo do projeto × catálogo global

Três dessas coleções — `categories`, `phases` e `disciplinesStatus` — **não são listas do projeto**.
Elas devolvem o **catálogo global da plataforma inteiro**, sempre com o mesmo número de itens em
qualquer projeto, e o que varia de projeto para projeto é a flag **`active`** de cada item:

- Projeto que **não** customiza o catálogo → **todos** os itens vêm com `active: true`.
- Projeto que customiza → só os escolhidos vêm com `active: true`; os demais continuam na lista, com
  `active: false`.

A lista **nunca vem vazia**, e "sem restrição" se manifesta como todo mundo ativo, não como ausência
de itens. **Sempre filtre por `active` antes de usar um identificador para escrever**: escrever com
categoria ou fase inativa é recusado (`CATEGORY_MUST_BE_ACTIVE` / `PHASE_MUST_BE_ACTIVE`).

O mesmo catálogo global, sem a anotação por projeto, está nas consultas de raiz
`systemIssueCategories`, `systemProjectPhases` e `systemIssueDisciplineStatus` — úteis quando você
precisa do vocabulário antes de saber de que projeto se trata. Para todo o resto, `project { … }`
já traz o que interessa, e com o `active` certo.

#### A forma de `locals` e `labels`

`locals` e `labels` vêm como `JSON` livre, e não como tipos fortes — os tipos `Local` e `Label` só
aparecem aninhados dentro do apontamento (`IssueLocal` / `IssueLabel`). Cada um devolve **um objeto
com duas visões dos mesmos nós**:

```json
{
  "flatten": [
    { "id": 201, "parentId": null, "name": "Torre A",     "abbreviation": "TA",
      "abbreviationTree": "TA",     "index": 0, "guid": "…", "projectId": 42 },
    { "id": 202, "parentId": 201,  "name": "Pavimento 2", "abbreviation": "P02",
      "abbreviationTree": "TA/P02", "index": 0, "guid": "…", "projectId": 42 }
  ],
  "tree": [
    { "id": 201, "parentId": null, "name": "Torre A", "abbreviation": "TA",
      "abbreviationTree": "TA", "index": 0, "guid": "…", "projectId": 42,
      "children": [ { "id": 202, "parentId": 201, "…": "… os mesmos campos …" } ] }
  ]
}
```

- **`flatten`** é a lista completa, sem aninhamento, ordenada por `id`. É a visão que você quer para
  indexar por identificador e para procurar por `abbreviationTree`.
- **`tree`** traz **só as raízes** (`parentId: null`), com os descendentes aninhados em `children` —
  e **o nó folha não tem a chave `children`**, ela simplesmente não aparece. A ordem em `tree` e em
  cada `children` é a de exibição (`index`), não a de `id`.
- Os nós de `tree` são os **mesmos objetos** de `flatten`, com os mesmos campos. Além dos mostrados
  acima, cada nó traz `oldId`, `createdAt`, `updatedAt` e `deletedAt` — trate-os como ruído.
- **`abbreviationTree`** é o caminho de abreviações da raiz até o nó, separado por **`/`** —
  `TA/P02/S05`. É a chave que a importação de BCF usa para casar locais
  ([§12](#como-os-campos-do-arquivo-são-interpretados)).
- Um nó cujo pai foi apagado aparece em `flatten` e **desaparece de `tree`**. Se você depende da
  árvore para navegação, reconstrua-a a partir de `flatten` e trate o órfão explicitamente.

#### Grupos

Um **grupo** é um subconjunto nomeado de usuários dentro de um projeto — a unidade que a visibilidade
`group` endereça: apontamento ou comentário criado com essa visibilidade é visto pelos membros do
grupo indicado, e não pelo projeto inteiro. O identificador vem de `project { groups { id name } }` e
é o que se envia em `groupId` nas operações de escrita ([§11](#11-escrita)).

⚠️ **A lista de grupos é recortada pela credencial: ela devolve apenas os grupos de que o usuário
dono é membro.** Não há como enumerar os demais grupos do projeto. Para escrever com visibilidade
`group`, ou o usuário da integração pertence ao grupo, ou o identificador tem de vir de fora da API.

#### Frequência

O cadastro muda raramente. Carregue-o **periodicamente** — uma vez por dia é folgado — e guarde do
seu lado; não o releia a cada rodada. **Não** use estas coleções para traduzir identificador em nome
nos apontamentos: os nomes vêm resolvidos na própria consulta do apontamento, e mais barato
([§8](#8-seleção-de-campos)). O cadastro serve para outro propósito: preencher um seletor na sua
interface e validar um valor **antes** de escrever ([§11](#11-escrita)).

---

## 7. Sincronização incremental

Este é o fluxo recomendado para manter uma cópia dos apontamentos e comentários no seu sistema. Ele
roda **projeto a projeto**, em três passos:

1. **Apontamentos alterados** na janela — inclui os excluídos, **sob pedido explícito**
   (`filter.includeDeleted: true`).
2. **Comentários alterados** na janela — inclui os excluídos, sempre, sem parâmetro.
3. **Detalhe** apenas da **união** dos dois primeiros.

Os dois primeiros passos dizem *o que mudou*; o terceiro busca *o detalhe* só do que mudou. É isso
que faz a carga da sua rotina acompanhar a **atividade da obra** em vez do **tamanho do acervo**.

### Por que são duas fontes, e não uma

> ⚠️ **O passo 2 não é opcional.**
>
> **Um comentário novo não altera a data de atualização do apontamento a que pertence.**
>
> Isso significa que o passo 1 **não** traz o apontamento que recebeu apenas um comentário. Sem o
> passo 2, toda conversa em apontamento que ninguém editou fica invisível para você — e comentário é
> justamente o que mais se move num projeto ativo.
>
> As duas consultas são **fontes independentes e ambas obrigatórias**. Cada comentário volta com o
> `issueId` do apontamento a que pertence: guarde esses identificadores, eles entram na lista do
> passo 3.

### A janela de data

Os dois primeiros passos usam o mesmo filtro de data, com a mesma semântica:

| Característica | Comportamento |
|---|---|
| Formato | ISO 8601 — `"2026-01-15"`, `"2026-01-15T10:00:00-03:00"`, `"2026-01-15T10:00:00Z"` |
| Data pura, sem hora (`"2026-01-15"`) | **Meia-noite UTC**, por especificação |
| Data e hora **sem fuso** (`"2026-01-15T10:00:00"`) | Interpretada como hora **local do processo do servidor** — não é UTC |
| Bordas | **Inclusivas nos dois lados** |
| Janela invertida (`From` > `To`) | Devolve **lista vazia, sem erro** |
| Valor inválido | **Silenciosamente ignorado** — o filtro simplesmente não é aplicado |

⚠️ **Envie sempre o fuso explícito** — `Z` ou `-03:00`. É a única forma que significa a mesma coisa
dos dois lados. Data e hora sem fuso são resolvidas contra o relógio do processo do servidor, que
não é contrato: você não controla esse valor e ele não é o seu. E cuidado com a data pura na borda
de cima: `updatedAtTo: "2026-01-31"` é meia-noite UTC e **exclui o dia 31 inteiro**.

Duas consequências que mudam o seu código:

**Bordas inclusivas significam duplicação, não perda.** Janelas adjacentes repetem o item que caiu
exatamente na borda. Isso é seguro e é o comportamento desejado — mas exige que a sua gravação seja
**idempotente**: grave por `upsert` sobre o identificador, nunca por `insert`.

⚠️ **Valor inválido ser ignorado em silêncio é a armadilha mais cara deste filtro.** Uma data mal
formatada não devolve erro: ela desliga o filtro, e a consulta vira uma **varredura total do
projeto**. A rodada "funciona", demora dez vezes mais e estoura os limites. Se uma rodada incremental
começou a trazer o acervo inteiro, o primeiro lugar a olhar é o formato das datas que você mandou.

⚠️ **Use o INÍCIO da rodada anterior como início da janela — nunca o fim dela, nem o início da rodada
atual.** Guarde o instante em que cada rodada começa: ele é o `updatedAtTo` da rodada corrente e o
`updatedAtFrom` da próxima. Usar o **fim** da rodada anterior perde tudo que foi alterado enquanto
ela estava executando.

```
janela desta rodada = [ instante em que a rodada ANTERIOR começou , instante em que ESTA rodada começou ]
```

O motivo é concreto: o filtro de data é reavaliado a cada página, e uma rodada leva minutos. Quem
começa a janela no **fim** da rodada anterior perde tudo que foi alterado enquanto ela executava — e
o que é editado *durante* a rodada entra ou não conforme a página em que estiver, produzindo uma
perda que não se repete e que ninguém percebe. Quem usa `updatedAtFrom = início desta rodada` perde
o intervalo inteiro entre as duas rodadas. Com bordas inclusivas e a janela encostada no **início**
da anterior, o pior caso é receber o mesmo item duas vezes — e a sua gravação já é idempotente.

### Passo 1 — apontamentos alterados

```graphql
query UpdatedIssues($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
  project(projectId: $projectId) {
    id
    issues(first: $first, after: $after, filter: $filter) {
      pageInfo { endCursor hasNextPage }
      issues {
        id guid code
        title description
        status priority visibility
        deadline
        category creationPhase resolutionPhase
        createdAt updatedAt editedAt statusUpdatedAt visibilityUpdatedAt
        createdByUserId editedByUserId statusUpdatedByUserId visibilityUpdatedByUserId
        deletedAt deletedByUserId
      }
    }
  }
}
```

Variáveis:

```json
{
  "projectId": 42,
  "first": 200,
  "after": null,
  "filter": {
    "updatedAtFrom": "2026-01-14T00:00:00Z",
    "updatedAtTo": "2026-01-15T00:00:00Z",
    "includeDeleted": true
  }
}
```

**A seleção acima é a recomendação.** Ela é deliberadamente enxuta: só escalares, nenhum objeto
aninhado. Custo zero de instanciação — é por isso que ela suporta `first: 200`, o teto
([§5](#forma-e-custo-da-consulta)) — e é tudo o que o passo 1 precisa entregar: a lista do que mudou,
para alimentar o passo 3. Os relacionamentos e os nomes vêm no detalhe.

🔴 **`includeDeleted: true` não é opcional nesta rotina.** Sem ele — que é o **padrão** — a listagem
devolve só os ativos, e a exclusão de apontamento fica **invisível** para você: a sua cópia acumula
para sempre registros que já não existem, sem erro nenhum e sem nada na resposta que denuncie a
ausência. É a diferença mais cara entre as duas fontes da sincronização, porque na de comentários o
excluído vem sem pedir. Detalhe do parâmetro em
[§10](#filterincludedeleted--pedir-os-excluídos).

**Com o pedido, o apontamento excluído vem na janela** — a exclusão carimba a data de atualização,
então cai na janela em que aconteceu, sem tratamento especial. Do seu lado a regra é uma linha:
**`deletedAt` nulo = ativo; preenchido = remover da sua base.**

⚠️ **Mas ele vem reduzido, não íntegro.** Chegam preenchidos apenas os identificadores — `id`,
`guid`, `code`, `projectId` — e a autoria da exclusão — `deletedAt`, `deletedByUserId`,
`deletedByUser`. **Todo o resto vem vazio, mesmo que você peça** na seleção: título, descrição,
situação, prioridade, visibilidade, prazo, datas de criação e edição e os usuários correspondentes
chegam nulos; as coleções (`comments`, `history`, `images`, `disciplines`, `labels`, `locals`,
`viewpoints`, referências a documentos e a modelos) chegam vazias. Pedir esses campos não é erro —
apenas não traz nada. Ramifique por `deletedAt` **antes** de ler qualquer outro campo, senão o
excluído vira, na sua base, um apontamento ativo sem título.

⚠️ **O `updatedAt` da excluída também não volta preenchido:** o recorte usa a data guardada, a
resposta não a devolve. Se o seu marcador de posição é o maior `updatedAt` da página, avance-o pelo
**fim da janela que você pediu** — nunca por um campo que nas excluídas chega nulo.

Apontamento excluído **não precisa de detalhe**: tire-o da lista do passo 3 — e não adianta tentar,
a consulta de detalhe responde "não encontrado" para excluído
([§10](#detalhe-de-um-apontamento)). O que a listagem entregou é tudo o que existe dele pela API.

⚠️ **Exclusões anteriores a 24/03/2025 não aparecem numa janela de data.** Até essa data a exclusão
era gravada de outra forma e **não** tocava o `updatedAt` — ele ficou congelado no valor anterior à
exclusão, e nenhum dado histórico foi reescrito. Para recuperá-las, faça **uma varredura completa**
(`includeDeleted: true`, sem `updatedAtFrom`/`updatedAtTo`) **uma única vez**, paginando até
`hasNextPage` ser `false`, e reconcilie contra a sua base; depois disso, o incremental basta.

### Passo 2 — comentários alterados

```graphql
query UpdatedComments($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
  project(projectId: $projectId) {
    id
    comments(first: $first, after: $after, filter: $filter) {
      pageInfo { endCursor hasNextPage }
      comments {
        id guid issueId
        message
        visibility issueVisibility
        createdAt updatedAt editedAt
        createdByUserId editedByUserId
        deletedAt deletedByUserId
        images { id index title original markedUp markedUpThumb }
      }
    }
  }
}
```

Variáveis: as mesmas do passo 1, **com `first: 50`** — esta seleção tem um relacionamento expandido
(`images`), e a regra de tamanho de página manda encolher a página nesse caso
([§5](#forma-e-custo-da-consulta)). Se você não trata imagens de comentário, tire `images` da seleção
e volte a `first: 200`.

Uma chamada por projeto. A consulta devolve uma **lista plana** de comentários do projeto inteiro —
não é preciso varrer apontamento por apontamento.

**O comentário excluído vem nesta mesma consulta**, com `deletedAt` e `deletedByUserId` preenchidos e
os demais campos íntegros. Mesma regra do passo 1: preenchido = remover da sua base.

O campo `issueVisibility` traz a visibilidade do apontamento pai, útil para você decidir o que expor
no seu lado sem precisar do detalhe.

### Passo 3 — detalhe da união

Monte a **união** de:

- os `id` do passo 1, **excluindo** os que vieram com `deletedAt` — esses você marca como removidos e
  não busca detalhe;
- os `issueId` dos comentários do passo 2, **exceto** os que apareceram só por exclusão de
  comentário: a exclusão já está no próprio comentário, e não há nada de novo no apontamento por
  causa dela.

```graphql
query IssueDetail($projectId: Int!, $issueId: Int!) {
  issue(projectId: $projectId, issueId: $issueId) {
    id guid code title description
    status priority visibility deadline
    createdAt updatedAt editedAt statusUpdatedAt
    createdByUserId editedByUserId statusUpdatedByUserId
    createdByUser { id name }

    category        categoryName        { id name }
    creationPhase   creationPhaseName   { id name }
    resolutionPhase resolutionPhaseName { id name }

    disciplines { disciplineId status deadline doneAt discipline { id name abbreviation } }
    locals      { localId  local  { id name abbreviation abbreviationTree parentId } }
    labels      { labelId  label  { id name abbreviation abbreviationTree parentId } }

    images { id guid index title original markedUp markedUpThumb }

    history { _id dataTime userId entityType fields }
  }
}
```

**Esta seleção é a recomendação para o passo 3**, e ela entrega de uma vez: os vínculos com nome
resolvido (disciplinas, locais, etiquetas), o nome de quem criou, as imagens e o histórico. Repare no
que **não** está nela: `comments` (você já os recebeu no passo 2 — pedi-los aqui traz o thread
inteiro, incluindo o que já estava sincronizado), `notifications`, `permissions`, `userPersonalData`,
`viewpoints` e `bimPins`. O porquê de cada ausência está em [§8](#8-seleção-de-campos).

`createdByUser { id name }` está aqui porque é barato resolvê-lo neste passo — um apontamento por
chamada, um objeto a mais. Na varredura do passo 1 ele não caberia. A alternativa, quando você
prefere guardar só o identificador, é ler a lista de membros do projeto de tempos em tempos
([§8](#deixe-a-consulta-resolver-os-nomes)).

O campo `history` traz `fields`, com o nome do campo alterado, o valor anterior e o valor novo — dá
para reconstruir o antes/depois sem nenhuma consulta adicional. **Não existe consulta incremental de
histórico**, então este é o único caminho; como você só visita o que mudou, o volume fica sob
controle.

#### Buscar o detalhe em lotes

O passo 3 é **o grosso das chamadas da rodada** — uma por apontamento alterado, contra duas por
projeto nos passos 1 e 2. Dá para reduzi-lo drasticamente: a listagem aceita
`filter: { ids: [...] }`, então você pode pedir o detalhe da união **em lotes paginados**, com a
mesma seleção acima, em vez de um a um.

```graphql
query IssueDetailBatch($projectId: Int!, $first: Int!, $after: String, $ids: [Int]!) {
  project(projectId: $projectId) {
    issues(first: $first, after: $after, filter: { ids: $ids }) {
      pageInfo { endCursor hasNextPage }
      issues { … a mesma seleção do detalhe, sem history … }
    }
  }
}
```

Use `first: 50` — a seleção tem relacionamentos expandidos
([§5](#forma-e-custo-da-consulta)) —, pagine até `hasNextPage` ser `false`, e leia
[o contrato de `filter.ids`](#filterids--o-filtro-estrito) antes: ele é o filtro estrito, recusa a
consulta inteira se um item vier fora de formato, e **não preserva a ordem que você informou**
([§10](#filterids--o-filtro-estrito)). Indexe a resposta pelo `id` de cada item, não pela posição.

**O que obriga a manter o passo 3 um a um é o `history`.** Na listagem ele é o log completo de
alterações **por apontamento** — numa página de 50, é a explosão que a
[seleção de campos](#campos-a-evitar-e-por-quê) manda evitar. Se você espelha o histórico, busque o
detalhe individualmente; se não espelha, o lote é o caminho.

### Carga inicial

A primeira execução usa **as mesmas consultas, sem `updatedAtFrom`** — uma varredura completa por
projeto, paginada até o fim. Ela traz o acervo e também os comentários já excluídos e, se você
mantiver o `includeDeleted: true` do passo 1, os apontamentos já excluídos — reduzidos ao aviso de
exclusão, como sempre. É esta varredura que recupera as exclusões antigas que nenhuma janela de data
alcança (apontamento, anteriores a 24/03/2025; comentário, anteriores a 08/2026).

⚠️ **Com uma ressalva: nem a carga inicial nasce completa.** Comentário de apontamento excluído
**nunca** é devolvido pela listagem de comentários — nem numa varredura sem janela de data
([§9](#o-que-você-não-detecta)). Se o seu modelo precisa da conversa de apontamentos já apagados,
esse dado não existe pela API, e nenhuma recarga o traz.

Depois disso, o incremental basta — com a recarga periódica de [§9](#9-exclusões-e-reconciliação)
como rede de segurança.

A carga inicial é a operação mais pesada da integração inteira. Faça-a fora do horário de pico, com
o mesmo limitador de taxa de sempre, projeto a projeto — não em paralelo entre projetos.

### Custo por rodada

Com **N** projetos, **A** apontamentos alterados e janelas curtas:

```
rodada normal =  1 (descoberta) + 2N (listagens) + detalhe
                 detalhe = A  (um a um)   ou   A / 50  (em lotes por filter.ids)

rodada em que o cadastro é relido = a de cima + N (uma chamada de cadastro por projeto)
```

O cadastro **não** entra em toda rodada: ele é relido periodicamente, não a cada passagem
([§6](#frequência)).

Um exemplo concreto: 50 projetos e 800 apontamentos alterados no dia.

| Estratégia do passo 3 | Chamadas | A ~4,5 req/s |
|---|---|---|
| Um a um | `1 + 100 + 800 = 901` | pouco mais de **3 minutos** |
| Em lotes de 50 | `1 + 100 + 16 = 117` | menos de **30 segundos** |

Nas rodadas em que o cadastro é relido, some 50 chamadas. O custo cresce com a **atividade da obra**,
não com o tamanho do acervo.

Acrescente **uma página vazia a mais por listagem** sempre que o total for múltiplo exato do tamanho
da página: a sinalização de próxima página tem falso positivo nesse caso
([§10](#paginação-e-cursor)). É esperado, e o laço converge.

---

## 8. Seleção de campos

Pedir menos campos é a economia mais barata que existe: não muda arquitetura e reduz tempo de
resposta, tráfego e custo dos dois lados. E é o que forma o preço da operação
([§5](#forma-e-custo-da-consulta)) — campo escalar folha custa zero; objeto aninhado custa, vezes o
tamanho da página.

### Campos a evitar, e por quê

| Campo | Por que evitar |
|---|---|
| `notifications` | ⚠️ É o **único** campo do apontamento que não é resolvido em lote: dispara uma consulta dedicada **por apontamento**. Numa página de 200, são 200 consultas escondidas atrás de um campo. Se você precisa do agregado, use `notificationsSummary { count mentions last }`, que é resolvido junto com o resto |
| `comments` dentro do detalhe | Repetição. Você já recebeu os comentários alterados no passo 2. Aqui ele traz o thread inteiro, incluindo tudo que já estava sincronizado |
| `comments` dentro da listagem | Explosão N×M: uma página de 50 apontamentos com `comments { images { ... } }` instancia milhares de objetos |
| `history` na listagem | Log completo de alterações **por apontamento**. No detalhe é aceitável; na varredura, não |
| `viewpoints`, `bimPins`, `ccodeDocumentsReferences`, `documentsReferences` | Dados pesados de BIM e de documentos, com conteúdo estruturado (câmeras, planos de corte, componentes). Ver abaixo |
| `permissions` | Diz o que **o usuário da credencial** pode fazer naquele apontamento. Raramente útil a um espelho, que replica dados e não autorização |
| `userPersonalData` | Etiquetas pessoais e favorito **do usuário da credencial** — não do autor do apontamento |
| `mentionedUsers` | Só faz sentido se você trata menções |
| `project { ... }` aninhado | Repete os dados do projeto em cada item da resposta. Busque o projeto uma vez, à parte |
| `createdByUser { ... }`, `editedByUser { ... }` **na varredura** | Um objeto por item. Na varredura, prefira o escalar `createdByUserId` / `editedByUserId`; peça o nome aninhado no **detalhe**, onde é um apontamento por chamada ([§7](#passo-3--detalhe-da-união)) |

⚠️ **`modelReferences` não é um campo a evitar — é um campo que não existe na saída.** Ele só existe
como **argumento de entrada** de `createIssue` / `updateIssue`. Pedi-lo numa seleção **falha a
consulta inteira** na validação do esquema (`Cannot query field "modelReferences" on type "Issue"`),
e nenhum dado volta. Não confunda com `ccodeDocumentsReferences` e `documentsReferences`, que são
campos de saída legítimos — pesados, mas resolvidos em lote.

### `viewpoints` e `bimPins` — só no fluxo BIM

São a posição do apontamento no modelo 3D. Vêm de outro banco, o conteúdo é estruturado e o tamanho
da resposta cresce rápido.

A regra é o fluxo, não o campo: **peça nos fluxos em que a posição 3D é usada, e deixe de fora nos
demais.** Numa varredura de sincronização eles quase nunca fazem falta; num fluxo de verificação de
modelo são exatamente o que importa.

### Deixe a consulta resolver os nomes

Quando você pede `disciplines { discipline { name } }`, `locals { local { ... } }`,
`labels { label { ... } }` ou `createdByUser { name }`, a API resolve tudo isso **em lote para todos
os itens da requisição** — não é uma consulta por apontamento. Peça esses nomes onde precisar deles:
é mais barato do que a alternativa e sempre traz o valor atual.

Para **nome de usuário** você tem duas opções, e as duas servem:

- **Aninhar na consulta** — `createdByUser { id name }` / `editedByUser { id name }`, resolvidos em
  lote como os demais. Traz sempre o valor atual, e é o caminho quando você precisa do nome em uma
  consulta que já está fazendo.
- **Ler a lista de membros do projeto** — `project(id: $projectId) { users { id name lastName email
  removed } }`. É a tradução de identificador em nome para o projeto inteiro, numa chamada, e
  também é a fonte dos identificadores quando você precisa **mencionar alguém** num comentário
  ([§11](#criar-e-editar-comentário)). Releia-a com frequência baixa, como o resto do cadastro; a
  marca `removed` diz quem saiu do projeto sem sumir do histórico.

⚠️ **O que não vale é guardar `createdByUserId` sem nunca resolver o nome** — nem aninhado, nem pela
lista de membros. Você fica com um identificador que o seu lado não sabe exibir.

O que **não** vale a pena é sincronizar os catálogos completos do projeto a cada rodada só para
traduzir identificador em nome. Isso troca uma resolução barata por um download recorrente de tabelas
inteiras, e ainda deixa a sua tradução desatualizada entre uma sincronização e outra.

Os catálogos de [§6](#6-descoberta) continuam sendo o lugar certo para **outro** propósito: quando
você precisa da lista completa de opções — preencher um seletor na sua interface, ou validar um valor
antes de escrever. Aí sim vale sincronizar, com frequência baixa.

### Rótulos que a API não traduz

`status`, `priority` e `visibility` vêm como **código**. Não existe `statusName` nem equivalente: o
de-para para português (ou para o vocabulário do seu sistema) é responsabilidade sua.

| Campo | Valores |
|---|---|
| `status` | `active` · `resolved` · `reproved` |
| `priority` | `low` · `medium` · `high` |
| `visibility` | `creator` · `group` · `project_management` · `public` |

O valor `group` endereça um **grupo do projeto** — um subconjunto nomeado de usuários. O conceito, de
onde vem o identificador e o que a API não expõe estão em [§6](#grupos).

A situação **por disciplina** (`disciplines { status }`) tem vocabulário próprio, e **quais valores
estão ativos é configuração de cada projeto** — consulte `disciplinesStatus` e filtre por `active`
([§6](#catálogo-do-projeto--catálogo-global)) em vez de fixar a lista no código. O conjunto completo:

| Situação da disciplina | Grupo | Mantém o apontamento ativo? |
|---|---|---|
| `participate`, `follow` | não responsável | não |
| `todo`, `awaiting`, `aware`, `doing`, `review` | ativa (pendência) | **sim** |
| `validate`, `validated` | resolvida | **sim** |
| `done` | resolvida | não |

⚠️ A terceira coluna é o que a escrita cobra. `validate` e `validated` são "resolvida" na leitura
humana e **mantêm o apontamento `active`** para a plataforma — é o desencontro que mais recusa
alteração ([§11](#alteração-parcial-as-cinco-regras)).

**Situação do apontamento não é estado de arquivo.** O apontamento está ativo ou excluído; `status` é
fluxo de trabalho, e apontamento `resolved` continua aparecendo normalmente em todas as consultas.
Não existe "arquivado".

### Imagens

Cada imagem traz três URLs, com papéis diferentes:

| Campo | O que é |
|---|---|
| `original` | O arquivo como foi enviado, sem marcações |
| `markedUp` | O render **com as marcações** — **é esta que se exibe** |
| `markedUpThumb` | Miniatura do render com marcações |

⚠️ **As URLs de imagem são públicas e permanentes.** Não expiram e não exigem autenticação: quem tiver
o endereço vê o arquivo, dentro ou fora do Construflow. Se você armazena essas URLs, elas herdam a
mesma característica na sua base — leve isso em conta antes de repassá-las a um sistema com controle
de acesso diferente do nosso, ou de embuti-las num relatório que circula por e-mail.

---

## 9. Exclusões e reconciliação

Uma cópia espelhada acerta o que chega. O problema de qualquer espelho é o que **não** chega — e
aqui a lista é conhecida. Esta seção é a matriz honesta do que você detecta e do que não detecta.

### O que você detecta

| O que aconteceu | Detecta? | Como |
|---|---|---|
| Apontamento criado ou alterado | ✅ | [§7 passo 1](#passo-1--apontamentos-alterados) — cai na janela de data |
| Apontamento **excluído** | ✅ **só se você pedir** | [§7 passo 1](#passo-1--apontamentos-alterados) — vem na listagem **apenas com `filter.includeDeleted: true`**, reduzido a identificadores + `deletedAt`/`deletedByUserId`. Sem o parâmetro, a exclusão é invisível |
| Comentário novo ou editado | ✅ | [§7 passo 2](#passo-2--comentários-alterados) |
| Comentário **excluído** | ✅ | [§7 passo 2](#passo-2--comentários-alterados) — vem na listagem com `deletedAt` e `deletedByUserId` |
| Disciplina, local ou etiqueta **desvinculada pela alteração do apontamento** | ✅ | A alteração carimba a data do apontamento; o detalhe traz os vínculos atuais |
| Projeto que muda de situação (ativo → inativo) | ✅ | O `status` em [§6](#6-descoberta) |

### O que você NÃO detecta

| O que aconteceu | Detecta? | Por que não |
|---|---|---|
| **Etiqueta ou local apagado no projeto** | ❌ | As linhas de vínculo são destruídas **sem tocar a data de atualização de nenhum apontamento**. Uma etiqueta apagada some de N apontamentos e **nenhum** entra na janela |
| Comentário de apontamento excluído | ❌ | A listagem de comentários não devolve comentário de apontamento excluído, esteja ele apagado ou não. Excluir um apontamento não marca os comentários dele — você fica sabendo pelo próprio apontamento. E não há a outra ponta: o excluído volta com `comments` vazio |
| **Conteúdo** de um apontamento excluído | ❌ | Não existe caminho na API. A listagem entrega o aviso de exclusão sem conteúdo, o detalhe responde "não encontrado", e as coleções aninhadas nele vêm vazias. O que você precisar do texto, das imagens ou do histórico tem de estar na **sua** cópia, gravado antes da exclusão |
| Mudança de **visibilidade** ou de **grupo** que tira o apontamento do seu acesso | ❌ | O apontamento simplesmente para de aparecer nas suas consultas. Não há evento, não há erro, e a sua cópia mantém para sempre um registro que a credencial já não pode ver |
| **Perda de acesso ao projeto** | ⚠️ parcial | Os apontamentos somem em silêncio — a listagem devolve **vazio, não erro**. Só duas coisas denunciam: o projeto **desaparecer** da lista de [§6](#6-descoberta), ou a consulta ao projeto passar a devolver erro de permissão. Uma rodada que voltou vazia num projeto ativo é sempre suspeita |

⚠️ **Repare no caso da etiqueta apagada**, porque ele é contraintuitivo: a desvinculação **pelo
apontamento** é detectável, e a desvinculação **pelo cadastro do projeto** não é. São dois caminhos
para o mesmo resultado visível, com sinais opostos. Se você usa etiqueta ou local para relatório, a
sua base vai acumular vínculos para itens que já não existem.

**Mitigação específica e barata:** toda vez que você reler o cadastro do projeto — periodicamente,
não a cada rodada ([§6](#frequência)) —, compare-o com os vínculos que você guardou. Etiqueta ou
local que sumiu do catálogo deve ser removido dos seus vínculos, sem precisar reler apontamento
nenhum. A defasagem é, no máximo, o intervalo entre duas releituras do cadastro.

### A recarga total periódica

Nenhuma das mitigações acima cobre tudo. Programe uma **recarga total periódica** por projeto —
mensal, por exemplo — como rede de segurança:

1. Rode as consultas do [passo 1](#passo-1--apontamentos-alterados) e do
   [passo 2](#passo-2--comentários-alterados) **sem janela de data**, paginando até o fim.
2. Compare o **conjunto de identificadores** recebido com o que você tem armazenado para aquele
   projeto.
3. O que existe na sua base e **não** veio na varredura: removeu-se, mudou de visibilidade, ou o
   acesso mudou. Em qualquer dos casos, não deveria mais estar visível para você.

É a única forma de fechar os buracos da tabela acima, e é prática padrão de qualquer base espelhada.
Agende fora do horário de pico e respeite o mesmo limitador de taxa da rodada normal
([§5](#dimensionamento)) — a recarga é pesada, mas não é urgente.

---

## 10. Consultas e filtros

Referência completa da leitura. A sincronização de [§7](#7-sincronização-incremental) usa um recorte
disto; esta seção é o vocabulário inteiro, para consulta sob demanda, relatório e busca.

### Listagem de apontamentos

Campo aninhado em `Project.issues`, paginado por cursor.

```graphql
query ListIssues($projectId: Int!, $first: Int!, $after: String, $filter: JSON) {
  project(projectId: $projectId) {
    id
    issues(first: $first, after: $after, filter: $filter) {
      issues {
        id guid code title description
        status priority visibility deadline
        category creationPhase resolutionPhase
        createdAt updatedAt editedAt
        createdByUserId editedByUserId
        deletedAt deletedByUserId
        disciplines { disciplineId status deadline doneAt discipline { name abbreviation } }
        locals { localId local { name abbreviationTree } }
        labels { labelId label { name abbreviationTree } }
        images { id index title markedUp }
      }
      pageInfo { endCursor hasNextPage }
    }
  }
}
```

**Retorno:** `IssuesPaginated { issues: [Issue], pageInfo: { endCursor, hasNextPage } }`.

A seleção acima é a de uma **tela de listagem** — traz os relacionamentos com nome resolvido, ao
custo de cerca de 15 objetos por apontamento. Por isso ela pede uma **página da ordem de 50**, não
200 ([§5](#forma-e-custo-da-consulta)). Para uma varredura de sincronização, use a seleção enxuta do
[passo 1](#passo-1--apontamentos-alterados), que é só escalar e suporta `first: 200`. Para o detalhe
de um apontamento específico, use [a consulta de detalhe](#detalhe-de-um-apontamento) em vez de
engordar a listagem.

### Paginação e cursor

| Argumento | Contrato |
|---|---|
| `first: Int` | Tamanho da página. **Máximo 200**, e ajustado à seleção ([§5](#forma-e-custo-da-consulta)). Passe sempre explícito — o padrão é **500**, acima do teto |
| `after: String` | Cursor: exatamente o `pageInfo.endCursor` da página anterior. `null` ou omitido na primeira página |
| `pageInfo.endCursor` | Cursor da última linha da página |
| `pageInfo.hasNextPage` | Sinaliza se há próxima página |

**O cursor é um inteiro não-negativo, e deve ser devolvido exatamente como veio.** Qualquer outra
coisa — texto, objeto, negativo, notação científica — **falha a consulta** com
`INVALID_PAGINATION_CURSOR`, antes de qualquer acesso ao banco.

⚠️ **`first` negativo não é recusado pelo teto — ele quebra a consulta.** O que existe é um teto de
200, não um piso: `first: -1` passa pela validação de forma e estoura mais adiante, com **`200` e um
erro interno genérico**. Trate `first` como inteiro entre 1 e 200 do seu lado.

⚠️ **A armadilha real é a dupla codificação.** `endCursor` é declarado `String` mas transporta um
número. Cliente que re-serializa a resposta e devolve o cursor com as aspas embutidas
(`"\"1615091\""`) é **recusado, não desembrulhado** — a plataforma aceita um formato só. Paginação que
funcionava e passou a falhar com `INVALID_PAGINATION_CURSOR` é, quase sempre, dupla codificação do
lado do cliente. Trate o cursor como opaco: guarde a string como veio e devolva a string como veio.

⚠️ **`hasNextPage` tem falso positivo quando o total é múltiplo exato do tamanho da página.** Com 400
resultados e `first: 200`, a segunda página vem cheia e ainda assim `hasNextPage` é `true`; a
terceira chamada devolve zero itens e `false`. **Itere até `hasNextPage` ser `false`** — uma chamada
vazia a mais é esperada e não é erro. Não pare no primeiro retorno vazio como se fosse anomalia, e
não deduza o fim pela contagem de itens.

### O objeto `filter`

`filter` é um objeto JSON livre. Todas as chaves são opcionais e **combinam via AND**.

| Chave | Tipo | O que faz |
|---|---|---|
| `standard` | `String` | Filtro predefinido (tabela abaixo) |
| `code` | `Int` | Casa o código do apontamento exatamente |
| `text` | `String` | Busca textual em título e descrição |
| `ids` | `[Int]` | Restringe a um conjunto explícito. ⚠️ **Validação estrita — não tolera item inválido e não trata `[]` como "sem filtro"** |
| `disciplineIds` | `[Int]` | Apontamentos associados a **qualquer** uma das disciplinas (OR entre os ids) |
| `labelIds` | `[Int]` | Apontamentos associados a **qualquer** uma das etiquetas (OR entre os ids) |
| `localIds` | `[Int]` | Apontamentos associados a **qualquer** um dos locais (OR entre os ids) |
| `ccodeDocumentId` | **`String`** | Apontamentos vinculados a um documento na **ConstruCode** (CCode), a plataforma externa de gestão documental integrada ao projeto. O identificador é **texto**, não número |
| `documentId` | **`String`** | Apontamentos vinculados a um documento do **CDE** (Common Data Environment) do próprio Construflow. Também **texto** |
| `withViewpoints` | `Boolean` | Liga o filtro "tem viewpoints". **`false` não traz os sem viewpoint — desliga o filtro** |
| `updatedAtFrom` | `String` | ISO 8601 — criados ou editados a partir desta data (inclusivo) |
| `updatedAtTo` | `String` | ISO 8601 — criados ou editados até esta data (inclusivo) |
| `includeDeleted` | `Boolean` | Traz também os apontamentos **excluídos**, junto com os ativos — reduzidos ao aviso de exclusão, **sem conteúdo**. Padrão `false` |

⚠️ **Chave que não está na tabela acima não existe — e não dá erro.** `filter` é um objeto livre: a
tabela desta seção é a lista **fechada** do que a listagem de apontamentos entende, e qualquer outra
chave é aceita, ignorada e respondida com status `200`, sem erro e sem aviso. São dois enganos
comuns, com o mesmo desfecho:

- **Grafia errada** — `{ disciplineId: 3 }`, sem o `s`: o filtro simplesmente não existe, e a
  consulta vira **varredura total do projeto**. É o mesmo desfecho de mandar uma data inválida.
- **Chave emprestada de outra consulta** — cada listagem tem a **sua** tabela de contrato, e elas não
  coincidem. `includeDeleted` vale aqui e **não existe** na [listagem de comentários do
  projeto](#comentários-do-projeto), onde é ignorado: lá o excluído vem sempre, e não há como pedir
  só os ativos.

🔴 **Resposta bem-sucedida não prova que o parâmetro existe.** Nada na resposta denuncia uma chave
ignorada — o único jeito de saber o que uma consulta aceita é a tabela de contrato dela, neste
documento. Toda vez que uma consulta filtrada devolver muito mais do que deveria, confira a **grafia
das chaves** e se elas pertencem a **esta** listagem antes de qualquer outra coisa.

> **Filtro nunca expande permissão.** Todos os filtros respeitam a visibilidade dos apontamentos
> (`creator`, `group`, `project_management`, `public`) e as permissões do usuário da credencial. Eles
> apenas **reduzem** o conjunto que a permissão já permitia; nunca o ampliam. Um resultado vazio pode
> significar "não existe" ou "você não pode ver" — não há como distinguir pela resposta.

#### `filter.standard`

| Valor | O que traz |
|---|---|
| `pendencies` | Apontamentos com pendências atribuídas ao usuário da credencial. **O conjunto de situações depende do perfil** — ver abaixo |
| `latePendencies` | Idem, mas apenas os com prazo vencido |
| `notifications` | Apontamentos com notificações não lidas para o usuário |
| `newMentions` | Apontamentos com menção **não lida** ao usuário — **de qualquer época** |
| `validate` | Apontamentos pendentes de validação. **Exige papel de coordenação** — ver abaixo |
| `starred` | Apontamentos marcados como favoritos pelo usuário |
| `actives` | Apenas os com `status = "active"` |
| *(omitido, `null` ou valor desconhecido)* | Todos os apontamentos visíveis ao usuário — valor fora da lista não é erro, é "sem filtro" |

Repare que quase todos são relativos **ao usuário da credencial**, não ao projeto. Numa integração,
`actives` costuma ser o único de uso geral.

⚠️ **`pendencies` não tem um conjunto fixo de situações — ele sai do perfil de quem consulta.** Quem
tem **papel de coordenação** no projeto vê mais situações do que um colaborador comum, incluindo
`validate` e `validated`, que este documento classifica como resolvidas
([§8](#rótulos-que-a-api-não-traduz)):

| Perfil no projeto | Situações de disciplina que contam como pendência |
|---|---|
| Coordenação / administração | `todo`, `awaiting`, `aware`, `doing`, `review`, `validate`, `validated` |
| Colaborador | `todo`, `awaiting`, `aware`, `doing`, `review` |
| Só visualização | nenhuma — devolve lista vazia |

`done` nunca conta como pendência, para nenhum perfil. E há uma segunda condição: o filtro exige que
o usuário seja **responsável por alguma disciplina** do apontamento — um coordenador sem disciplina
atribuída recebe lista vazia.

⚠️ **`validate` devolve sempre lista vazia para credencial sem papel de coordenação.** Não é erro,
não é "nada pendente": é o filtro não sendo aplicável ao perfil. Se a sua integração usa `validate`,
confirme antes que o usuário dono da chave tem esse papel no projeto.

⚠️ **`newMentions` não tem janela temporal.** É menção **não lida**, sem corte de data: uma menção de
2023 que ninguém marcou como lida continua aparecendo. Ela só sai da lista quando a notificação é
apagada — o usuário marca como lida, o apontamento é excluído, ou o usuário perde a visibilidade
dele.

#### `filter.code`

- Tipo `Int`. Aceita também string numérica (`"1234"`) — coerção automática.
- Valor não numérico (`"abc"`), `null` ou omitido é **silenciosamente ignorado**: o filtro não é
  aplicado.

#### `filter.ids` — o filtro estrito

⚠️ **Este filtro não segue a tolerância dos vizinhos.** Onde `disciplineIds` / `labelIds` /
`localIds` descartam item inválido em silêncio e degradam para "sem filtro", `ids` **recusa a
consulta inteira**. Se você escreveu o seu cliente por analogia com os outros filtros de lista, é
aqui que ele quebra.

- Tipo `[Int]`. Item válido = inteiro **positivo**, ou a string numérica equivalente (`"1234"`).
- **Qualquer item inválido** — `0`, negativo, `null`, string não numérica, objeto — faz a consulta
  falhar com `INVALID_ISSUES_IDS_FILTER`. Nada é descartado em silêncio e nenhum apontamento é
  devolvido.
- Valor que **não é lista** (`12`, `"12"`, `{}`) produz a mesma recusa.
- ⚠️ **`[]` significa conjunto vazio, não "sem filtro".** Devolve **zero** apontamentos — o oposto de
  `disciplineIds: []`, que desliga o filtro. **A assimetria é deliberada:** "quero exatamente estes
  apontamentos, e a lista está vazia" não pode degradar para "quero todos". Um cliente que monta a
  lista dinamicamente e às vezes a monta vazia precisa **omitir a chave**, não mandar `[]`.
- Chave **omitida**, `null` ou `undefined` ⇒ filtro não aplicado — aí sim, igual aos vizinhos.
- Ids duplicados são deduplicados.
- ⚠️ **A ordem informada não é preservada, e a ordem do array na resposta não é garantida em nada.**
  A resposta é servida em parte por cache, que devolve primeiro o que estava quente — a mesma
  consulta pode voltar em ordens diferentes. **Indexe pelo `id` de cada item e ordene do seu lado.**
  Isso não afeta a paginação: o **cursor** continua confiável, porque a página é recortada por
  identificador crescente, não pela ordem do array.
- Id que existe mas não é visível ao usuário simplesmente não casa nada. A recusa acima é sempre
  sobre **formato**, nunca sobre existência ou permissão.

#### `filter.disciplineIds`, `filter.labelIds`, `filter.localIds`

Os três têm exatamente a mesma semântica:

- Tipo `[Int]`. Cada elemento deve ser inteiro **positivo**; string numérica (`"3"`) é aceita e
  convertida; `0`, negativos, `null`, `NaN` e strings não numéricas são **silenciosamente
  ignorados**; duplicados são deduplicados.
- **OR entre os ids da mesma chave**, **AND entre chaves diferentes**.
- Consideram apenas vínculos ativos: vínculo removido é ignorado mesmo que o id corresponda.
- ⚠️ **Match exato — não expande hierarquia.** Filtrar por um local pai (um pavimento) **não** inclui
  apontamentos vinculados apenas aos filhos (as salas); o mesmo vale para etiquetas. Se você quer a
  subárvore, envie também os ids dos filhos — a árvore vem em [§6](#6-descoberta).
- Id que não pertence ao projeto, ou inexistente, simplesmente não casa nada — sem erro, sem aviso.
- `[]`, `null`, `undefined` ou chave omitida ⇒ **filtro não aplicado** (as quatro formas são
  equivalentes).

#### `filter.text`

- Busca **substring, sem diferenciar maiúsculas**, no título e na descrição.
- A string é **tokenizada por espaço**. Cada token vira um filtro independente combinado via **AND**,
  e cada token, individualmente, casa em título OU descrição.
- `%`, `_` e `$` são tratados como literais — não atuam como curinga.
- String vazia ou só com espaços é ignorada. Comprimento truncado em **200** caracteres.

| `filter.text` | Casa em |
|---|---|
| `"infiltração"` | título OU descrição contendo "infiltração" |
| `"rodrigo ferreira"` | contém "rodrigo" **e** (independentemente) contém "ferreira" — inclusive `"Ferreira, rodrigo"` |
| `"trinca pequena"` | "trinca" e "pequena" aparecem, em qualquer combinação de título/descrição |
| `"50%"` | casa `50%` literal — `%` não é curinga |

#### `filter.updatedAtFrom` / `filter.updatedAtTo`

- Devolve apontamentos **criados ou editados** no período. Cada borda é opcional; ambas são
  **inclusivas**.
- ISO 8601, e **envie sempre o fuso explícito**. Só a **data pura** (`"2026-01-15"`) é UTC por
  especificação — e é meia-noite UTC, então `updatedAtTo: "2026-01-31"` exclui o dia 31 inteiro. Data
  **com hora e sem fuso** (`"2026-01-15T10:00:00"`) é resolvida contra o relógio do processo do
  servidor, que não é contrato. Detalhe em [§7](#a-janela-de-data).
- `updatedAtFrom` maior que `updatedAtTo` devolve **lista vazia, sem erro**.
- Valor inválido (não-string, data não parseável, `null`, `""`) é **silenciosamente ignorado** — e a
  consulta vira varredura total.
- ⚠️ **Não filtra a data dos comentários.** Comentário novo não altera a data de atualização do
  apontamento. Para comentários, use [a listagem de comentários do projeto](#comentários-do-projeto).

#### `filter.includeDeleted` — pedir os excluídos

Sem este parâmetro a listagem devolve **só os ativos**, exatamente como sempre devolveu. Com ele, o
conjunto de sempre **mais** os apontamentos excluídos. É a **única** via da API para descobrir que um
apontamento foi apagado — e é o passo 1 da sincronização ([§7](#passo-1--apontamentos-alterados)).

- **Ligam o filtro:** o booleano `true` e a string `"true"`. ⚠️ **Qualquer outro valor** (`1`, `"1"`,
  `"TRUE"`, `null`, chave omitida) cai no padrão `false`, **sem erro e sem aviso** — um valor "quase
  certo" devolve silenciosamente só os ativos, e a sua detecção de exclusão nunca dispara.
- **Nada muda nos ativos:** nenhum apontamento ativo sai da resposta, nenhum campo deles muda. O que
  muda é o **conteúdo dos excluídos**, que vêm reduzidos a identificadores e autoria da exclusão —
  ver [§7 passo 1](#passo-1--apontamentos-alterados) para a lista exata do que chega preenchido.
- **É honrado em todos os recortes por atributo** — `updatedAtFrom`/`updatedAtTo`, `code`, `text`,
  `disciplineIds`, `labelIds`, `localIds`, `ids`, `ccodeDocumentId`, `documentId`, `withViewpoints` —,
  que passam a recortar também sobre os excluídos.
- ⚠️ **O recorte enxerga o conteúdo do excluído; a resposta não o devolve.** Um apontamento excluído
  pode entrar na página por casar `text`, `disciplineIds` ou `withViewpoints` e ainda assim chegar com
  título, descrição, disciplinas e viewpoints vazios. Não tente conferir pelo payload por que ele
  casou, nem reaplicar o filtro no cliente sobre o que voltou — você descartaria exclusões legítimas.
- ⚠️ **É ignorado junto com um `filter.standard` de trabalho** (`pendencies`, `latePendencies`,
  `notifications`, `newMentions`, `validate`, `starred`, `actives`): a resposta traz só ativos, sem
  erro. Esses recortes descrevem o trabalho corrente do usuário, e apontamento excluído não está
  nele. Se você precisa dos excluídos, não use `standard`.
- **Não afeta permissão nem visibilidade:** apontamento que a credencial não podia ver continua
  invisível, excluído ou não.
- **Não muda ordenação nem cursor** — os excluídos contam como itens comuns da página.

### Erros de formato

A doutrina geral deste contrato é **tolerância**: filtro malformado é ignorado e a consulta segue.
Mas a tolerância não é universal — há entradas fora de formato que **derrubam a consulta**, e elas se
dividem em duas famílias muito diferentes na hora de tratar.

**Família 1 — as duas com código próprio e mensagem legível.** São as únicas recusas de formato que
você pode reconhecer programaticamente:

| Entrada fora de formato | `errors[0].extensions.code` |
|---|---|
| `after` (cursor de paginação) | `INVALID_PAGINATION_CURSOR` |
| `filter.ids` | `INVALID_ISSUES_IDS_FILTER` |

⚠️ **As duas respondem `200`, não `400`.** O campo consultado é nulável, então o erro **nulifica o
campo** e a resposta permanece `200` com `data` presente e `issues` nulo. É mais um caso concreto da
regra de [§4](#as-três-formas-de-recusa): **verifique `errors` antes de ler `data`** — quem lê `data`
direto vê um campo nulo e conclui "o projeto não tem apontamentos". O `extensions` traz **só** o
`code`; a mensagem legível vem em `errors[0].message`.

A regra do cursor vale igualmente para a
[listagem de comentários do projeto](#comentários-do-projeto), que usa o mesmo cursor. Quanto ao
momento: o **cursor** é validado antes de qualquer acesso ao banco; o **`filter.ids`** é validado um
pouco depois, já com o registro de acesso gravado — a diferença não muda o que você faz, mas a
recusa de `ids` não é tão barata quanto a do cursor.

**Família 2 — as que produzem erro interno genérico.** Também derrubam a consulta, também com `200`,
mas **sem código próprio**: chegam como `INTERNAL_SERVER_ERROR`, com uma mensagem crua vinda da
camada de dados. Não há nada a distinguir programaticamente:

| Entrada fora de formato | O que acontece |
|---|---|
| `filter.documentId` com número (`123`) em vez de texto (`"123"`) | erro interno — o campo é **texto** |
| `filter.ccodeDocumentId` com objeto | erro interno |
| `first` negativo | erro interno |

Não escreva tratamento por código para estas: previna-as validando o tipo do seu lado. Se uma
consulta que "sempre funcionou" passou a devolver `INTERNAL_SERVER_ERROR`, o primeiro lugar a olhar é
o **tipo** dos valores que você montou no `filter`.

A tolerância vale para o **conteúdo** de um filtro reconhecido. Ela **não** vale para a forma nem
para o tamanho da consulta: profundidade, tamanho de página, preço e array de operações têm tetos
próprios, e a recusa deles é `400` com código próprio
([§4](#as-três-formas-de-recusa), [§5](#forma-e-custo-da-consulta)).

### Exemplos

Filtro predefinido:

```graphql
query {
  project(projectId: 42) {
    issues(first: 20, filter: { standard: "actives" }) {
      issues { id code title status priority deadline }
      pageInfo { endCursor hasNextPage }
    }
  }
}
```

Busca textual com tokenização:

```graphql
query {
  project(projectId: 42) {
    issues(first: 20, filter: { text: "infiltração laje" }) {
      issues { id code title description }
      pageInfo { endCursor hasNextPage }
    }
  }
}
```

Por etiquetas e locais (OR dentro de cada chave, AND entre elas):

```graphql
query {
  project(projectId: 42) {
    issues(first: 20, filter: { labelIds: [11, 12], localIds: [201] }) {
      issues {
        id code title
        labels { labelId label { name } }
        locals { localId local { name abbreviationTree } }
      }
      pageInfo { endCursor hasNextPage }
    }
  }
}
```

Janela de alteração — as duas pontas:

```graphql
query {
  project(projectId: 42) {
    issues(
      first: 200
      filter: {
        updatedAtFrom: "2026-01-01T00:00:00Z"
        updatedAtTo: "2026-01-31T23:59:59Z"
      }
    ) {
      issues { id code title updatedAt deletedAt }
      pageInfo { endCursor hasNextPage }
    }
  }
}
```

Combinação (AND entre as chaves):

```graphql
query {
  project(projectId: 42) {
    issues(
      first: 20
      filter: { standard: "actives", text: "umidade", disciplineIds: [101, 102], labelIds: [11] }
    ) {
      issues { id code title }
      pageInfo { endCursor hasNextPage }
    }
  }
}
```

### Detalhe de um apontamento

```graphql
query IssueById($projectId: Int!, $issueId: Int!) {
  issue(projectId: $projectId, issueId: $issueId) { id guid code title status updatedAt }
}
```

**Argumentos:** `projectId: Int!`, `issueId: Int!`. **Retorno:** `Issue` — todos os campos da
listagem, mais `categoryName`, `creationPhaseName`, `resolutionPhaseName`, `history`, `comments`,
`viewpoints`, `bimPins`, `notificationsSummary` e as referências de documento
(`ccodeDocumentsReferences`, `documentsReferences`). **Não** existe `modelReferences` na saída —
pedi-lo falha a consulta ([§8](#campos-a-evitar-e-por-quê)). A seleção completa recomendada está em
[§7 passo 3](#passo-3--detalhe-da-união).

Use para o detalhe de um apontamento específico, depois de selecioná-lo numa listagem. Para buscar o
detalhe de **muitos** apontamentos, prefira a listagem com `filter.ids`, em lotes paginados
([§7](#buscar-o-detalhe-em-lotes)) — o laço um a um só se justifica quando você precisa de
`history`.

**Erros:**

- Apontamento **excluído** e apontamento **inexistente** respondem igual — `NOT_FOUND` (semântica de
  "não encontrado"), com status `200` e o erro em `errors[0]`. São indistinguíveis por esta consulta,
  de propósito: nos dois casos não há apontamento visível para aquele identificador.
- Apontamento de outro projeto, ou sem permissão de visualização, responde `FORBIDDEN`.

> ⚠️ **Esta consulta nunca devolve apontamento excluído.** Se a sua sincronização recebeu o
> identificador de um excluído pela listagem, não tente completar aqui o que faltou lá: o detalhe
> responde "não encontrado", e **o conteúdo de um apontamento excluído não é acessível por nenhum
> caminho da API** — a listagem já entregou tudo o que existe dele
> ([§7 passo 1](#passo-1--apontamentos-alterados)).

> ⚠️ **Mudança de contrato:** identificador inexistente respondia erro genérico de servidor
> (`INTERNAL_SERVER_ERROR`) e passou a responder `NOT_FOUND`. Se o seu cliente aplica backoff e retry
> em erro de servidor ([§4](#o-que-pode-ser-retentado)), um identificador inválido deixa de virar
> retentativa — trate `NOT_FOUND` como resposta final.

### Comentários de um apontamento

Campo aninhado em `Issue.comments`. Traz o **thread completo** do apontamento, sem paginação.

```graphql
query IssueComments($projectId: Int!, $issueId: Int!) {
  issue(projectId: $projectId, issueId: $issueId) {
    id
    comments {
      id guid issueId message visibility
      createdAt updatedAt editedAt
      createdByUserId editedByUserId
      deletedAt deletedByUserId
      groupId
      images { id index title markedUp markedUpThumb }
    }
  }
}
```

**Retorno:** `[Comment]`.

Serve a uma tela de conversa. **Não** serve a sincronização: para acompanhar comentários em massa,
use a listagem plana abaixo.

### Comentários do projeto

Campo aninhado em `Project.comments`. Devolve todos os comentários visíveis do projeto — **inclusive
os excluídos** — numa lista plana e paginada, com filtro opcional por data de alteração. É a consulta
do passo 2 da sincronização ([§7](#passo-2--comentários-alterados)).

**Argumentos:**

| Argumento | Contrato |
|---|---|
| `first: Int` | Tamanho da página. **Máximo 200**, e ajustado à seleção ([§5](#forma-e-custo-da-consulta)). Passe sempre explícito — o padrão é **500**, o mesmo da listagem de apontamentos |
| `after: String` | Mesmo contrato do cursor de apontamentos: inteiro não-negativo, devolvido como veio. Fora de formato ⇒ `INVALID_PAGINATION_CURSOR`, com status `200` |
| `filter.updatedAtFrom` | ISO 8601 — comentários criados, editados **ou excluídos** a partir desta data (inclusivo) |
| `filter.updatedAtTo` | ISO 8601 — idem, até esta data (inclusivo) |

⚠️ **A tabela acima é fechada — não existe `includeDeleted` nesta consulta.** Ele é chave da
[listagem de apontamentos](#o-objeto-filter); aqui é aceito, ignorado e respondido com `200`, sem
erro e sem aviso. O comentário excluído vem **sempre**, e não há como pedir só os ativos.

**Retorno:** `CommentsPaginated { comments: [Comment], pageInfo: { endCursor, hasNextPage } }`.

Notas de comportamento:

- Ordenação fixa por ordem de criação; o cursor é o identificador do último comentário da página.
- Data inválida é silenciosamente ignorada. ⚠️ **O cursor não é** — a tolerância vale para os filtros
  de data, não para a paginação.
- A página pode devolver **até `first` comentários visíveis**: a visibilidade é avaliada **depois** da
  paginação, então uma página pode vir com menos itens do que o pedido sem que isso signifique fim
  dos dados. Itere até `hasNextPage` ser `false`.
- O conjunto devolvido é exatamente o que o usuário já enxerga via `Issue.comments`. Esta consulta
  achata a listagem; não expande o acesso.

**Comentário excluído permanece na listagem**, com `deletedAt` e `deletedByUserId` preenchidos e os
demais campos íntegros. A exclusão conta como alteração e carimba a data de atualização, então cai na
janela em que aconteceu, sem tratamento especial: `deletedAt` nulo = ativo, preenchido = excluído.

⚠️ **Comentário de apontamento excluído não é devolvido**, esteja ele apagado ou não — excluir um
apontamento não marca os comentários dele. E também não há como alcançá-los pela outra ponta: o
apontamento excluído volta com `comments` vazio. Quem precisa reagir à exclusão observa o próprio
apontamento, pedindo os excluídos na listagem
([§7 passo 1](#passo-1--apontamentos-alterados)).

⚠️ **Atenção à assimetria com apontamentos — ela é de forma e de conteúdo.** Aqui o excluído vem
**sempre**, sem parâmetro, e **com a mensagem íntegra**; lá o apontamento excluído vem **só sob
pedido** (`filter.includeDeleted: true`) e **sem conteúdo algum** — apenas identificadores e autoria
da exclusão. Quem espelha as duas entidades não consegue tratá-las pelo mesmo caminho de código: o
comentário excluído continua um registro legível na sua base; do apontamento excluído não há o que
espelhar além do identificador para removê-lo.

---

## 11. Escrita

Escrever exige permissão de **participar** do projeto. Sem ela, a operação falha na autorização — com
status `200` e a lista `errors` preenchida ([§4](#4-erros-e-recusas)).

Uma exceção: **copiar apontamentos entre projetos exige papel de gestão no projeto de destino**
([lote](#criação-em-lote-e-cópia--as-duas-restrições-que-surpreendem)). Participar não basta ali.

### Criar apontamento

```graphql
mutation CreateIssue {
  createIssue(
    projectId: 42
    title: "Infiltração na laje do 3º pavimento"
    description: "Mancha de umidade junto ao pilar P12."
    status: "active"
    priority: "high"
    visibility: "public"
    category: 3
    creationPhase: 9
    locals: [201]
    labels: [11]
    disciplines: [{ disciplineId: 101, status: "todo", deadline: "2026-02-10" }]
  ) {
    id guid code title status deadline
  }
}
```

**Obrigatórios no contrato — são cinco:** `projectId`, `title`, `status`, `priority`, `visibility`.

**Obrigatórios pelas regras de negócio**, embora o esquema os aceite ausentes:

| Argumento | Quando é exigido | O que acontece se faltar |
|---|---|---|
| `locals` | **sempre** | ⚠️ ver o aviso abaixo |
| `disciplines` | **sempre** | ⚠️ ver o aviso abaixo |
| `category` | quando o projeto tem o catálogo de categorias configurado | recusa `CATEGORY_MUST_BE_ACTIVE`, **antes** de gravar |
| `creationPhase` | quando o projeto tem o catálogo de fases configurado | recusa `PHASE_MUST_BE_ACTIVE`, **antes** de gravar |
| `resolutionPhase` | **sempre que `status` não for `"active"`** | recusa `MISSING_RESOLUTION_PHASE`, antes de gravar |

Se o projeto não restringe categorias e fases, `category` e `creationPhase` podem ser omitidos — o
projeto aceita o catálogo global inteiro ([§6](#o-cadastro-do-projeto)). Consulte o cadastro para
saber em qual caso você está.

🔴 **Omitir `locals` ou `disciplines` cria o apontamento e só depois estoura.** A validação desses
dois vínculos acontece **depois** da gravação, e não há transação: você recebe um erro interno
genérico, sem o apontamento na resposta — mas o apontamento **existe**, gravado, com código
consumido e sem local nem disciplina. **Não reenvie**: reenviar duplica. Se você mandou um `guid`
próprio, dá para reconciliar; senão, o registro fica órfão. Mandar `locals: []` ou `disciplines: []`
é diferente e seguro: recusa limpa, antes de gravar
([regra 3](#alteração-parcial-as-cinco-regras)). **Sempre envie os dois, não-vazios.**

⚠️ **Não envie `deadline` no topo.** O prazo do apontamento é **derivado das disciplinas** — como
`disciplines` é obrigatório aqui, o valor que você mandar no topo é sempre descartado. O prazo se
define no `deadline` de cada item de `InputIssueDiscipline`
([regra 5](#alteração-parcial-as-cinco-regras)).

**Demais argumentos aceitos:** `description: String`, `deadline: Date`, `labels: [Int]`,
`images: [IssueImageInput]`, `groupId: Int`, `guid: String`,
`ccodeDocumentsReferences: [IssueCcodeDocumentReferenceInput]`,
`documentsReferences: [IssueDocumentReferenceInput]`, `modelReferences: [ModelReferenceInput]`,
`viewpoints: [IssueViewpointInput]`, `bimPins: JSON`, `aiAssistantLogs: JSON`.

Os valores de `status`, `priority` e `visibility` estão em [§8](#rótulos-que-a-api-não-traduz). Os
identificadores de `locals`, `labels`, `disciplines` e `groupId` vêm do cadastro do projeto
([§6](#6-descoberta)); os de `category`, `creationPhase` e `resolutionPhase` vêm de um catálogo
global, e **precisam estar `active` neste projeto**
([§6](#catálogo-do-projeto--catálogo-global)). **Valide-os antes de escrever**: identificador de
outro projeto não é aceito, e categoria ou fase inativa é recusada.

Você pode enviar o seu próprio `guid` na criação. É a forma de amarrar o apontamento a um registro do
seu sistema sem manter uma tabela de correspondência: o mesmo `guid` depois identifica o apontamento
na alteração.

**Input types:**

```graphql
input InputIssueDiscipline {
  disciplineId: Int!
  status: String            # obrigatório na prática — item sem status derruba a operação
  deadline: Date            # omitir ZERA o prazo gravado da disciplina
  doneAt: Date              # omitir SOBRESCREVE a data de conclusão gravada — ver a regra 4
  remove: Boolean           # lido SÓ pela mutation em lote — ver a regra 4
}

input IssueImageInput {
  id: Int
  guid: String
  title: String!            # obrigatório
  cover: Boolean
  deleteImage: Boolean
  viewpoint: JSON
  markUp: JSON
  index: Int
  original: String          # URL, após o upload pela URL assinada
  originalFile: Upload      # alternativa por multipart
  markedUp: String          # a versão que se exibe — ver "Imagens em três passos"
  markedUpFile: Upload
  markedUpThumb: String     # gerado pelo servidor apenas se markedUp estiver preenchido
  markedUpThumbFile: Upload # ≤200×200 PNG, proporção preservada
}
```

### Alterar apontamento

```graphql
mutation UpdateIssue {
  updateIssue(
    projectId: 42
    issueId: 1234
    status: "resolved"
    resolutionPhase: 9
    disciplines: [
      { disciplineId: 101, status: "done", doneAt: "2026-01-20", deadline: "2026-02-10" }
    ]
  ) {
    id guid title status deadline updatedAt
  }
}
```

**Obrigatório:** `projectId`. O apontamento é identificado por `issueId` **ou** por `guid` — o que for
mais conveniente para o seu lado.

**Demais campos:** os mesmos de `createIssue`, inclusive as referências de documento e modelo. Envie
só os que mudaram — veja as regras abaixo.

Repare no `deadline` dentro da disciplina, no exemplo: ele não é decoração. Sem ele, esta chamada
**apagaria** o prazo da disciplina **e** o prazo do apontamento, em silêncio (regra 5).

### Alteração parcial: as cinco regras

A alteração é parcial: **campo não enviado é preservado.** Não é preciso reenviar o apontamento
inteiro — e não se deve, porque cada campo enviado é um campo que pode sobrescrever a edição de
outra pessoa.

Cinco regras governam o que acontece com o que você **envia**:

1. ⚠️ **Enviar `status` exige `resolutionPhase` no mesmo envio** — exceto quando o status enviado é
   `active`. O gatilho é **enviar**, não **mudar**: reenviar o mesmo status que já estava lá, sem
   repetir `resolutionPhase`, é **recusado** com `MISSING_RESOLUTION_PHASE`. Um cliente que monta o
   payload a partir do objeto inteiro cai nisso na primeira alteração. Para não mexer na situação,
   **omita `status`**.

   E o contrário também vale: enviar `status: "active"` **apaga a `resolutionPhase` gravada**, mesmo
   que você mande um valor junto. Numa reabertura, o registro de em que etapa o apontamento tinha
   sido resolvido se perde.
2. ⚠️ **`status` e `disciplines` têm de ser coerentes entre si.** O invariante é simples: o
   apontamento está `active` **se e somente se** ao menos uma disciplina está em situação ativa.

   | Situações de disciplina no envio | `status` exigido |
   |---|---|
   | ao menos uma em `todo`, `awaiting`, `aware`, `doing`, `review`, `validate` ou `validated` | `"active"` — qualquer outro é recusado com `DISCIPLINE_STATUS_MUST_KEEP_ISSUE_ACTIVE` |
   | todas em `done`, `participate` ou `follow` | **não** `"active"` — recusado com `DISCIPLINE_STATUS_MUST_KEEP_ISSUE_NOT_ACTIVE` |

   ⚠️ **`validate` e `validated` mantêm o apontamento ativo**, embora sejam situações de "resolvido"
   na leitura humana ([§8](#rótulos-que-a-api-não-traduz)). Mandar as disciplinas como `validate`
   **obriga** `status: "active"`.

   Enviar `disciplines` **sem** `status` não é a saída: num dos caminhos a operação é recusada por
   falta de fase de resolução, exigindo um campo (`autoResolutionPhase`) que **só existe no input da
   mutation em lote** — na alteração individual não há como satisfazê-la. Fechar todas as
   disciplinas de um apontamento ativo, portanto, exige enviar `status` junto.
3. ⚠️ **Lista vazia apaga — em uns campos, e é recusada em outros.**

   | Campo | `[]` significa |
   |---|---|
   | `labels` | **apaga todas** as etiquetas |
   | `ccodeDocumentsReferences`, `documentsReferences`, `modelReferences` | **apagam todas** as referências |
   | `locals` | **recusado** — o apontamento precisa de ao menos um local |
   | `disciplines` | **recusado** — o apontamento precisa de ao menos uma disciplina |

   Para **preservar** o valor atual, **omita o campo**. Nunca mande `[]` com a intenção de "não
   mexer". Este é o erro que mais apaga dado em integração nova, porque muitos clientes serializam
   uma coleção vazia onde deveriam omitir a chave.
4. ⚠️ **A lista de disciplinas enviada é o estado final — é substituição total.** Disciplina que
   está no apontamento e **não** vem na lista é **removida**. Para remover uma, omita o item e
   reenvie as demais; para preservar todas, reenvie todas.

   **`remove: true` não funciona aqui.** O campo existe no input e é **descartado** na alteração
   individual — só a mutation em lote o lê ([lote](#escrita-em-lote)). Mandar `remove: true` com a
   situação presente **mantém** a disciplina; se o `disciplineId` não estava no apontamento, ela é
   **criada**. E mandar `remove: true` **sem** a situação não é ignorado com elegância: responde erro
   interno.

   E, dentro de cada item, **campo omitido não é preservado**: um item `{ disciplineId, status }` sem
   `deadline` **zera** o prazo gravado da disciplina, e sem `doneAt` **sobrescreve** a data de
   conclusão gravada — pela data de hoje, nas situações que exigem conclusão (`validate`,
   `validated`, `done`). **Reenvie os dois sempre que quiser mantê-los.**

   O servidor ainda normaliza os dois conforme a situação, e o que ele normaliza você não controla:
   em `participate` e `follow` o prazo é anulado, tenha sido enviado ou não; a data de conclusão é
   anulada em todas as situações exceto `validate`, `validated` e `done`; e nessas três, se você não
   mandar `doneAt`, entra a data de hoje.
5. ⚠️ **O prazo do apontamento é derivado das disciplinas — você não o define diretamente.** Sempre
   que `disciplines` é enviado, o servidor **sobrescreve** `deadline` com o **maior prazo entre as
   disciplinas enviadas** — ou com **nulo**, se nenhuma delas tiver prazo. Um `deadline` mandado no
   topo, junto com `disciplines`, é descartado sem aviso.

   Consequência prática: uma alteração que só quis fechar uma disciplina, e mandou
   `[{ disciplineId, status: "done", doneAt }]` sem `deadline`, **apaga o prazo do apontamento** —
   sem erro, sem aviso. Mandar `deadline` isolado (sem `disciplines`) grava, mas não resolve: a
   próxima alteração que enviar disciplinas sobrescreve de novo. Trate o prazo como propriedade das
   disciplinas.

### Escrita em lote

Para criar ou alterar muitos apontamentos, use as mutations em lote em vez de disparar chamadas
individuais:

| Mutation | Para quê |
|---|---|
| `createIssues` | Criar vários apontamentos |
| `updateIssues(projectId: Int!, issuesIds: [Int]!, newValues: IssueNewValuesInput): JSON` | Aplicar **os mesmos** novos valores a um conjunto de apontamentos |
| `updateIssuesDisciplinesStatus(projectId: Int!, issuesIds: [Int!]!, disciplines: [IssueDisciplineStatusInput!]!, autoResolutionPhase: Int): IssuesDisciplinesStatusResult` | Mover **só a situação** de disciplinas escolhidas num conjunto de apontamentos — a única escrita que alcança quem **não** pode alterar o apontamento |
| `copyProjectIssues` | Copiar apontamentos de um projeto para outro |

#### `updateIssues` é uma operação **parcial** — leia o retorno

O retorno é `JSON`, e tem esta forma:

```json
{
  "updatedIssues": [ { "id": 1234, "…": "…" } ],
  "rejectedIssues": [ { "id": 1240, "error": { "status": 403, "code": "ISSUE_MUST_HAVE_LOCALS" } } ]
}
```

🔴 **Apontamento rejeitado não derruba a mutation.** A resposta HTTP é `200`, a lista `errors` do
GraphQL vem **vazia**, e as rejeições estão só em `rejectedIssues`. Quem checa apenas `errors`
conclui que tudo passou e nunca descobre o que ficou para trás. **Sempre percorra `rejectedIssues`.**

O objeto `error` de cada rejeição traz `status` e `code`; **não traz a mensagem**. Trate pelo `code`.

#### O input do lote não é o mesmo da alteração individual

`IssueNewValuesInput` parece o input de `updateIssue`, e não é:

| Campo | No lote |
|---|---|
| `locals`, `labels` | **Não são listas de ids.** São `{ "action": "add" \| "remove", "ids": [Int] }`. `action` fora desses dois valores derruba a mutation inteira |
| `disciplines` | **Mesclagem**, não substituição: item com `disciplineId` já presente tem só as chaves enviadas atualizadas (omitir `deadline` **preserva** o prazo — o oposto da alteração individual); item novo só entra se tiver `status`, senão é descartado em silêncio; **`remove: true` funciona aqui** e tira a disciplina |
| `autoResolutionPhase: Int` | Existe **só** no lote. É a fase de resolução usada quando o fechamento das disciplinas leva o apontamento a sair de `active` sozinho |
| `images` | Declarado no input e **nunca processado** — aceito e descartado em silêncio |
| `guid`, `viewpoints`, `bimPins`, referências de documento e de modelo | **Não existem** no input do lote |

⚠️ **Enviar `status: "resolved"` ou `"reproved"` no lote reescreve para `done` todas as disciplinas
ativas dos apontamentos alcançados** — inclusive as que você não mencionou.

⚠️ **O lote regrava a linha inteira**, a partir do estado que leu no início da operação — não só os
campos que você mandou. Edição feita por outra pessoa nesse intervalo é revertida, inclusive em campo
que o seu `newValues` nem menciona. A janela é curta, mas existe, e é maior que a da alteração
individual ([concorrência](#não-atropelar-quem-está-editando)).

🔴 **Identificador inexistente em `issuesIds` nem sempre vira rejeição — às vezes derruba a rodada
inteira.** Depende do que você está alterando:

| O `newValues` inclui | Identificador inexistente |
|---|---|
| Nenhum de `disciplines`, `locals`, `labels`, `status` | vai para `rejectedIssues`; os demais são gravados |
| Qualquer um deles | **erro interno, e nada é gravado** — nem os apontamentos válidos do lote |

É a diferença entre perder um item e perder a rodada, e o caso perigoso é justamente o mais comum
(alterar situação ou disciplinas). Filtre `issuesIds` contra o que você sabe existir antes de enviar.
Identificador de **outro projeto** é sempre rejeitado individualmente, sem esse risco.

#### `updateIssuesDisciplinesStatus` — mover a situação das disciplinas

Esta operação move **a situação das disciplinas** de um ou mais apontamentos, e nada mais. Ela é
separada de `updateIssues` por causa de quem a alcança: é a **única** escrita de apontamento
disponível a quem **não pode alterá-lo** — quem participa do projeto e responde por uma disciplina
move a situação dela por aqui, sem ter permissão para tocar no resto do apontamento.

`updateIssues` continua aceitando `disciplines` exatamente como antes, e nada foi removido dela: quem
já integrou pelo lote comum não precisa mudar nada.

```graphql
mutation MoverSituacaoDeDisciplinas {
  updateIssuesDisciplinesStatus(
    projectId: 42
    issuesIds: [1234, 1240]
    disciplines: [{ disciplineId: 101, status: "doing" }]
    autoResolutionPhase: 9
  ) {
    updatedIssues { id status deadline updatedAt disciplines { disciplineId status } }
    rejectedIssues { id code message status }
  }
}
```

Cada item de `disciplines` tem dois campos, e **os dois são obrigatórios**:

| Campo | O que é |
|---|---|
| `disciplineId` | A disciplina a mover. Ela precisa **já estar** no apontamento |
| `status` | A situação de destino ([§8](#rótulos-que-a-api-não-traduz)), entre as que estão ativas no catálogo do projeto |

Os mesmos itens valem para **todos** os `issuesIds` — como no lote comum, não existe alteração
heterogênea.

⚠️ **A lista não é o estado final: mande só as disciplinas que se movem.** É o oposto da alteração
individual, em que a lista substitui tudo ([alteração parcial, regra 4](#alteração-parcial-as-cinco-regras)).
Aqui, disciplina que você **não** mencionar fica intacta — mesma situação, mesmo prazo, mesma data de
conclusão. Prazo e data de conclusão você não escreve nem por engano: os campos não existem neste
input.

⚠️ **`autoResolutionPhase` é exigido quando o gesto tira a última disciplina que mantinha o
apontamento ativo** — é a fase de resolução com que ele se fecha, como no lote comum. Faltando nesse
caso, o apontamento é recusado com `MISSING_RESOLUTION_PHASE`. Se o seu gesto não fecha apontamento
nenhum, omita o campo.

**Quem pode mover o quê:**

| Quem chama | O que consegue mover |
|---|---|
| quem pode alterar o apontamento | qualquer disciplina dele, para qualquer situação ativa no catálogo do projeto |
| quem **não** pode alterá-lo, mas responde pela disciplina | **só as disciplinas pelas quais responde**, e **só para os destinos que a situação atual alcança** |

Nessa segunda faixa, três limites não se contornam: de `validated` e de `done` não se sai; para
`validated` e para `done` não se entra — fechar uma disciplina é sempre de quem coordena; e o
apontamento precisa ser visível para a sua credencial, senão ele é recusado como inexistente.

**O que a operação recusa.** Prazo, data de conclusão, acrescentar disciplina e retirar disciplina
**não existem** neste input: o GraphQL recusa o pedido inteiro com erro de campo desconhecido, antes
de qualquer coisa ser gravada. Para esses gestos o caminho continua sendo `updateIssue` ou
`updateIssues`. O resto vira rejeição do apontamento, com `code` e `status`:

| Situação | `code` | `status` |
|---|---|---|
| apontamento inexistente — **ou** existente e invisível para a sua credencial | `ISSUE_NOT_FOUND` | 404 |
| apontamento de outro projeto | `ISSUE_DOES_NOT_BELONGS_TO_PROJECT` | 403 |
| disciplina pedida não está no apontamento | `DISCIPLINE_NOT_IN_ISSUE` | 400 |
| disciplina fora do catálogo do projeto | `DISCIPLINE_NOT_FOUND` | 404 |
| situação desligada no catálogo do projeto | `DISCIPLINE_STATUS_NOT_AVAILABLE` | 403 |
| você não responde pela disciplina, ou o destino não é alcançável a partir da situação atual | `FORBIDDEN` | 403 |
| o gesto fecharia o apontamento e `autoResolutionPhase` não veio | `MISSING_RESOLUTION_PHASE` | 400 |

⚠️ **Invisível e inexistente respondem igual, de propósito.** Nos dois casos vem `ISSUE_NOT_FOUND` —
não conclua que o identificador está errado.

🔴 **A recusa é do apontamento inteiro, e o retorno é parcial.** Basta uma disciplina recusada para
que **nada** daquele apontamento seja gravado: as outras que vieram no mesmo pedido não se movem. Os
demais apontamentos da lista seguem normalmente, e valem aqui as mesmas advertências de
[`updateIssues`](#updateissues-é-uma-operação-parcial--leia-o-retorno) — resposta `200`, lista
`errors` do GraphQL **vazia**, rejeições só em `rejectedIssues`. Percorra-a sempre.

Uma diferença de forma, se você reaproveita o tratamento de retorno do outro lote: aqui a rejeição é
**plana e traz a mensagem** — `{ "id": 1240, "code": "…", "message": "…", "status": 403 }` —, em vez
do objeto `error` aninhado e sem mensagem de `updateIssues`. A resposta também é tipada:
`updatedIssues` é uma lista de apontamentos, com os mesmos campos de qualquer consulta.

#### Criação em lote e cópia — as duas restrições que surpreendem

⚠️ **`createIssues` grava imagens, mas não passa pelo serviço de upload.** O que você manda em
`images` é persistido como veio, sem tratamento. Na prática:

- **Só a URL já hospedada tem efeito** — use o [fluxo de URL assinada](#imagens-em-três-passos), suba
  o arquivo antes e informe a URL pronta.
- **Envio de arquivo direto é ignorado** (`originalFile`, `markedUpFile`, `markedUpThumbFile`).
- **Nenhuma miniatura é gerada**, e a versão com marcações cai de volta para a original.
- **`cover: true` é descartado** — o lote nunca define a capa do apontamento.

Se você precisa de miniatura ou de capa, crie o apontamento pelo caminho individual (`createIssue`),
ou crie em lote e anexe as imagens depois por `updateIssue` — os dois passam pelo pipeline completo.

O input do lote também **não aceita** `viewpoints`, as referências de documento, as referências de
documento CCode nem as de modelo.

🔴 **`copyProjectIssues` exige papel de gestão no projeto de destino** — não basta a permissão de
participar que serve para todo o resto da escrita. Na origem basta visualizar. É a única mutation de
apontamento que sai do padrão, e a recusa vem como erro de permissão genérico.

#### Duas observações que evitam desenho errado

- **Não existe operação em lote heterogênea.** `updateIssues` aplica o mesmo conjunto de valores a
  todos os identificadores da lista. Alterações diferentes para apontamentos diferentes são chamadas
  diferentes.
- **Lote não é agrupamento de operações.** Uma mutation em lote é **uma** operação GraphQL com muitos
  itens, e é recomendada. Enviar um **array** de operações numa chamada HTTP é outra coisa, e é
  recusada ([§5](#forma-e-custo-da-consulta)).

### Não atropelar quem está editando

**Não há trava.** Duas escritas concorrentes sobre o mesmo apontamento não conflitam: a última vence,
em silêncio. O que existe é o material para você detectar a concorrência antes de escrever, sem custo
nenhum:

- `editedAt` e `editedByUserId` marcam **quem** tocou por último e **quando**, e avançam em qualquer
  edição.
- Leia o apontamento, guarde o `editedAt`, e compare-o no momento de escrever. Se mudou, alguém
  editou no intervalo — decida se sobrescreve, mescla ou descarta.

Reduzir a janela é mais eficaz do que detectá-la: **envie a alteração no momento em que ela
acontece**, em vez de acumular numa fila que drena de tempos em tempos. A janela de conflito é
proporcional ao tempo que a alteração espera enfileirada; enviando na hora, ela quase desaparece — e
boa parte da leitura prévia deixa de ser necessária.

### Criar e editar comentário

```graphql
mutation CreateComment {
  createProjectIssueComment(
    projectId: 42
    issueId: 1234
    message: "Reparo executado em 20/01, com registro fotográfico."
    visibility: "public"
  ) {
    id issueId message visibility createdAt createdByUserId
    images { id title original markedUp markedUpThumb }
  }
}
```

**Obrigatórios:** `projectId`, `issueId`, `message`, `visibility`. **Demais argumentos:**
`images: [IssueCommentImageInput]`, `mentionedUsers: [Int]`, `groupId: Int`.

```graphql
mutation UpdateComment {
  updateProjectIssueComment(projectId: 42, commentId: 987, message: "Texto corrigido.") {
    id message visibility editedAt editedByUserId
  }
}
```

**Obrigatórios:** `projectId`, `commentId`. **Demais argumentos:** `message: String`,
`visibility: String`, `images: [IssueCommentImageInput]`, `mentionedUsers: [Int]`, `groupId: Int`.
Campos omitidos permanecem inalterados — **com uma exceção destrutiva**:

🔴 **Omitir `mentionedUsers` apaga todas as menções do comentário.** É o único campo em que a regra
"campo omitido é preservado" **não** vale. Quem edita só o texto e não reenvia a lista perde as
menções, sem erro e sem aviso — e não há como recuperá-las pela API, porque o tipo `Comment` não
expõe as menções na leitura. **Reenvie sempre a lista completa de `mentionedUsers`**, mesmo quando
ela não mudou.

⚠️ `images` na edição é **aditivo**: os itens enviados são acrescentados. Para remover uma imagem,
mande o item com `{ id, deleteImage: true }`.

**Input de imagem do comentário:**

```graphql
input IssueCommentImageInput {
  id: Int
  type: String
  title: String!
  cover: Boolean
  deleteImage: Boolean
  viewpoint: JSON
  markUp: JSON
  index: Int
  original: String
  originalFile: Upload
  markedUp: String
  markedUpFile: Upload
  markedUpThumb: String
  markedUpThumbFile: Upload
}
```

⚠️ **Criar um comentário não altera a data de atualização do apontamento.** Se a sua integração
escreve comentários e também sincroniza, ela não vai reencontrar o próprio comentário pela janela de
apontamentos — só pela janela de comentários ([§7](#por-que-são-duas-fontes-e-não-uma)).

### Imagens em três passos

O binário nunca passa pela API. O caminho é sempre: pedir uma URL assinada, subir o arquivo
diretamente no armazenamento, e referenciar a URL final na mutation.

**Passo 1 — pedir as URLs:**

```graphql
mutation UploadSignedUrls($projectId: Int!, $filenames: [String!]!) {
  createProjectIssueFileUploadSignedUrls(projectId: $projectId, filenames: $filenames)
}
```

**Retorno:** `JSON` — uma lista de `{ signedUrl, contentType }`, **na mesma ordem de `filenames`**.
Lista vazia devolve `[]`.

⚠️ **O casamento é por POSIÇÃO, nunca por nome.** Do nome o servidor usa só a extensão — a chave do
objeto é um identificador que ele gera. Dois arquivos de mesmo nome são dois itens distintos, e ler o
retorno por nome entrega o link de um arquivo a outro: o anexo abre o conteúdo errado, sem erro
nenhum.

| Característica | Valor |
|---|---|
| Teto por chamada | **50 nomes** |
| Acima do teto | recusa com `UPLOAD_URL_BATCH_LIMIT_EXCEEDED`, e **nenhuma** URL é emitida |
| Armazenamento | **Google Cloud Storage** (`storage.googleapis.com`) |
| Validade da URL | **1 hora** |
| Método aceito | **`PUT`** |
| Balde de taxa | Próprio (`user-upload-url`), separado do de mutations ([§5](#a-tabela-de-limites)) |

A versão de um nome só, `createProjectIssueFileUploadSignedUrl(projectId, filename)`, continua
existindo e devolve o objeto direto em vez da lista. Ela está **obsoleta** — resolve pelo mesmo
caminho da plural, que atende um nome ou muitos.

⚠️ **Não existe campo `key` nem `fileUrl` no retorno.** A URL final do arquivo é a `signedUrl` **sem a
query string** — corte tudo a partir do `?`.

**Passo 2 — subir o arquivo:**

```
PUT {signedUrl}
Content-Type: {contentType devolvido no passo 1}
Corpo: os bytes do arquivo
```

⚠️ **Não acrescente cabeçalhos ao `PUT`.** Em particular, **não envie `x-goog-acl`**: ele não faz
parte da assinatura, e a requisição passa a ser recusada pelo armazenamento — com um erro que vem do
Google, não do Construflow, e que confunde o diagnóstico. A leitura pública do arquivo vem da
política do bucket, não de permissão por objeto. Envie o `Content-Type` que a mutation devolveu e
nada mais.

**Passo 3 — referenciar na mutation:**

Use a URL final (sem query string) ao criar ou alterar o apontamento ou o comentário — e preencha
**`original` e `markedUp` com ela**, deixando `markedUpThumb` de fora:

```graphql
images: [{ title: "fachada-leste.jpg", original: "<url final>", markedUp: "<url final>" }]
```

🔴 **A miniatura só é gerada se `markedUp` estiver preenchido.** Quem faz os três passos e envia
**só `original`** fica **sem miniatura**, em silêncio: nenhum erro, nenhum aviso, e a imagem aparece
sem capa e sem versão reduzida — a interface passa a carregar o arquivo em tamanho cheio onde
deveria haver miniatura. É a armadilha do fluxo de URL assinada, e ela não existe no caminho por
multipart (`originalFile`), onde o servidor preenche `markedUp` sozinho.

Duas formas de fazer certo, e a primeira basta:

- **Repita a URL em `markedUp`** e omita `markedUpThumb` — o servidor baixa o arquivo, gera o PNG
  (≤200×200, proporção preservada) e o publica.
- Ou suba a sua própria miniatura por uma segunda URL assinada e preencha `markedUpThumb`.

⚠️ A URL precisa ser a **URL pública final** do arquivo — a `signedUrl` sem a query string. Se você
passar a URL assinada inteira, com a query string, a geração da miniatura falha em silêncio e você
volta ao mesmo desfecho.

Se você subiu **duas** versões do arquivo — o original e uma cópia com marcações —, aí sim
`original` e `markedUp` recebem URLs diferentes, e a miniatura é gerada a partir da versão com
marcações.

Peça as URLs **do gesto inteiro numa chamada só**, e sob demanda. A URL vale uma hora e o balde de
emissão é pequeno, então o que não se faz é pré-alocar para depois: pedir N URLs em N chamadas
paralelas é o que estoura o balde, e pedir antes da hora as desperdiça.

---

## 12. Importação de verificação de modelo (BCF)

A importação de um arquivo `.bcf` cria (ou atualiza) apontamentos no projeto a partir dos tópicos do
arquivo. É o caminho recomendado para trazer o resultado de uma verificação de modelo BIM para dentro
do Construflow — não há endpoint melhor.

São três passos, e os dois primeiros precisam acontecer em sequência imediata.

### Passo 1 — abrir o processo de importação

```
POST /bcf-server/bcf/import-process/{projectId}
Content-Type: application/json
```

Corpo:

| Parâmetro | Obrigatório | Valores | O que faz |
|---|---|---|---|
| `visibility` | **sim** | `creator` · `project_management` · `public` | Visibilidade com que os apontamentos do arquivo entram no projeto. ⚠️ **Não use `group`** — ver abaixo |
| `commentVisibility` | se o arquivo tiver comentários | mesmos valores acima | Visibilidade com que os comentários do arquivo entram |
| `nonResponsibleDisciplinesStatus` | não (padrão `participate`) | `participate` · `follow` | Como entram as disciplinas listadas em `Labels` que não são a responsável |
| `includeBcfTopicNumber` | não (padrão `false`) | booleano | Prefixa o título com o número do tópico no arquivo (`BCF#12`) |

**Resposta:** `200` com `{ "signedUrl": "..." }`.

🔴 **`visibility: "group"` faz falhar TODOS os tópicos.** A importação não tem como dizer de que
grupo se trata — não existe parâmetro para isso —, então a verificação de permissão nunca passa e
cada tópico é recusado com erro de permissão. Não há caminho em que funcione. Use `creator`,
`project_management` ou `public` ([§6](#grupos)).

> ⚠️ **`commentVisibility` não é um campo do BCF** — é uma decisão de importação que o arquivo não
> carrega. O padrão BCF não tem noção de visibilidade, então é aqui que se define quem enxerga os
> comentários importados. **Se o arquivo trouxer comentários e esse parâmetro não for enviado, cada
> comentário falha individualmente** — os apontamentos entram normalmente, e você só descobre a
> perda no e-mail de resultado. Se há qualquer chance de o arquivo ter comentários, envie sempre.

⚠️ **`commentVisibility` não aceita os mesmos valores da visibilidade do apontamento, e o que você
pede não é o que necessariamente é gravado.** Três diferenças:

- **`group` é descartado** na importação. Pedi-lo não é erro: o comentário entra com a maior
  visibilidade abaixo de `group` que a sua permissão permitir — na prática, `creator`.
- **O valor funciona como teto, e é rebaixado em silêncio** conforme a permissão de quem importa e a
  visibilidade do apontamento. Um usuário que só participa do projeto pedindo `public` num
  apontamento `public` recebe comentários **`creator`** — sem erro, sem aviso.
- E, quando **nenhuma** visibilidade permitida cabe abaixo do teto pedido, o comentário entra com a
  **menor visibilidade disponível acima dele** — ou seja, o teto pode ser furado **para cima**. Se a
  confidencialidade importa, não confie no parâmetro: dimensione a **permissão** do usuário da
  integração pela visibilidade que você quer de fato, e confira a `visibility` dos comentários
  depois da importação.

### Passo 2 — subir o arquivo

```
PUT {signedUrl}
Corpo: os bytes do arquivo .bcf
```

> ⚠️ **A janela é de 200 segundos** a partir do passo 1. O processamento começa a procurar o arquivo
> assim que o processo é aberto, e desiste depois desse tempo. Suba o arquivo **imediatamente** após
> receber a URL — não trate os dois passos como estágios de filas separadas, nem enfileire a
> abertura do processo para "subir depois".

🔴 **A URL vive muito mais que a janela, e é aí que se perde o arquivo.** A URL assinada vale quase
**sete dias**; a janela de processamento é de **200 segundos**. Subir o arquivo depois desses 200
segundos **tem sucesso** — o armazenamento aceita o `PUT` e devolve `200` — e **não faz nada**: o
processamento já desistiu, o arquivo fica órfão no armazenamento, nenhum apontamento é criado,
nenhum e-mail é enviado. O seu lado vê um upload bem-sucedido e uma importação que nunca aconteceu.
Um `PUT` com sucesso **não** é confirmação de importação.

### Passo 3 — o resultado

O resultado chega **por e-mail ao usuário dono da credencial**: quantos apontamentos foram criados,
quantos foram atualizados, e a lista de tópicos que falharam com o motivo de cada um.

⚠️ **Não há consulta de andamento nem de resultado por API.** O e-mail é a única saída. Duas
consequências que precisam entrar no seu desenho:

- Se a credencial pertence a um usuário de serviço cuja caixa de entrada ninguém lê, a importação é
  **cega** — ela pode falhar inteira sem que você fique sabendo. Aponte esse endereço para uma caixa
  monitorada, ou trate o e-mail programaticamente.
- Uma importação sem retorno não é sinal de sucesso. Confirme o efeito pela API, consultando os
  apontamentos do projeto pela janela de data ([§7](#passo-1--apontamentos-alterados)).

⚠️ **Abrir o processo não valida nada.** O passo 1 devolve `200` e uma URL assinada mesmo que o
projeto não exista, esteja inativo, ou a credencial não tenha acesso a ele. Toda a validação acontece
no processamento, e chega por e-mail.

A permissão é conferida em **dois momentos diferentes**, e a diferença muda o desfecho:

| O que falta | Quando é conferido | Desfecho |
|---|---|---|
| Permissão de **visualizar** o projeto | logo no início, antes de qualquer tópico | 🔴 **A importação inteira morre.** Nenhum tópico é tentado, e o e-mail traz **um único erro genérico**, sem identificar tópico nenhum |
| Permissão de **participar** do projeto (ou projeto inativo) | na gravação de cada apontamento | Os tópicos falham **um a um**, cada um com o seu motivo no e-mail |

Um e-mail de resultado sem nenhum tópico identificado é o sinal de falha global — comece checando o
acesso da credencial ao projeto.

### Comportamento da importação

- **Versões suportadas:** BCF **2.1** e **3.0**.
- **Reimportar atualiza, não duplica.** A identificação é pelo identificador (GUID) do tópico: se já
  existe um apontamento com aquele identificador no projeto, ele é **atualizado**. É o comportamento
  desejado quando a verificação de modelo é rodada de novo sobre o mesmo escopo.
- ⚠️ **A reimportação sobrescreve** título, descrição, situação, prioridade, categoria, fases,
  **locais**, **disciplinas** — e, junto com as disciplinas, a **situação de cada uma**, o **prazo** e
  a **data de conclusão** delas —, mais o **prazo do apontamento** e a **fase de resolução**.
  **Preserva** visibilidade, etiquetas e as imagens já existentes. Na prática: **entre duas
  verificações, o arquivo `.bcf` é a fonte da verdade desses campos** — ajuste feito à mão no
  Construflow nesse intervalo é substituído sem aviso.

  O que isso custa, em concreto: uma disciplina que a equipe moveu para `doing` ou `review` **volta**
  para a situação que o arquivo diz; o prazo do apontamento é **recalculado a partir das disciplinas
  do arquivo** — e zerado, se nenhuma delas trouxer prazo
  ([regra 5](#alteração-parcial-as-cinco-regras)); e a **fase de resolução é forçada a nulo**, porque
  o arquivo traz o apontamento como ativo. Quem usa BCF em ciclos deve tratar o Construflow como
  espelho do arquivo nesses campos, não como o lugar onde o andamento é registrado.
- ⚠️ **Locais e disciplinas são substituição total, não fusão.** O que foi acrescentado à mão no
  Construflow e **não** está no arquivo é **removido**. Não há como manter um local ou uma disciplina
  extra entre reimportações.
- **Comentários não são duplicados** na reimportação (também identificados por GUID). Em compensação,
  comentário **editado** no arquivo **não** atualiza o que já entrou.
- ⚠️ **O identificador do tópico é único em toda a plataforma.** O mesmo GUID não pode existir em dois
  projetos. Gerador de BCF que reaproveita identificadores entre escopos faz **aquele tópico** falhar
  no segundo projeto — só ele, e a importação segue com os demais. O apontamento do primeiro projeto
  **não é tocado**.

#### Viewpoints não são sobrescritos

Os viewpoints são a exceção da lista acima, e o comportamento deles é o menos intuitivo da
importação. São quatro casos, decididos pelo identificador (GUID) de cada viewpoint:

| No arquivo | O que acontece |
|---|---|
| Os mesmos identificadores de antes, na mesma ordem | **Nada muda.** Nem sequer há gravação |
| Identificador que **já existe** no apontamento | O conteúdo **antigo é mantido**. Câmera, corte e imagem alterados no arquivo são **ignorados** |
| Identificador **novo** | É acrescentado, com o conteúdo do arquivo |
| **Nenhum** viewpoint no tópico | **Apaga todos** os viewpoints existentes |

🔴 **Não existe "atualizar um viewpoint".** Reposicionar a câmera de um viewpoint já importado e
reimportar o arquivo **não tem efeito** — o GUID é o mesmo, então o conteúdo antigo prevalece, em
silêncio. Para trocar o conteúdo, o gerador de BCF precisa emitir o viewpoint com um **GUID novo**.
E cuidado com o caminho inverso: um tópico que **perdeu** o viewpoint no arquivo apaga os que
existiam no Construflow.

⚠️ O identificador lido é o **`Guid` do arquivo de visualização** (`.bcfv`), não o do tópico. Gerador
que não emite esse atributo entrega viewpoints **sem identificador**, e aí o servidor sorteia um novo
a cada importação — o conjunto passa a ser substituído sempre, que é o oposto do que a tabela acima
descreve. Se você gera o arquivo e quer o comportamento previsível, emita o `Guid` do `.bcfv` e
mantenha-o estável entre rodadas.

### Como os campos do arquivo são interpretados

| Campo do BCF | Vira no Construflow | Como casa | Confira contra |
|---|---|---|---|
| `Priority` | Prioridade | Valor **em português**, exato — ver o alerta abaixo | — |
| `TopicStatus` | Situação | Valor conhecido, exato — ver abaixo | — |
| `AssignedTo` | Disciplina(s) responsável(is) | Pela **abreviação** da disciplina | cadastro do **projeto** |
| `Labels` **sem** prefixo | Disciplinas participantes | Pela **abreviação** da disciplina | cadastro do **projeto** |
| `Labels` **com** prefixo `^` | Locais | Pelo **caminho hierárquico de abreviações**, separado por `/` | cadastro do **projeto** |
| `TopicType` | Categoria | Pelo **número** antes do hífen | catálogo **global** |
| `Stage` | Fase | Pela **abreviação** antes do hífen | catálogo **global** |
| `DueDate` | Prazo das disciplinas de `AssignedTo` | — | — |
| `Title` | Título | Um prefixo no formato exato `NNNN - ` (4 ou 5 dígitos, espaço, hífen, espaço) é **removido**: `12345 - Vazamento` entra como `Vazamento`. Fora desse formato o número **permanece** no título — `123 - …`, `123456 - …`, `1234-Título` e espaçamento duplo não são tocados | — |

Valor que não casar **faz o tópico falhar**, com o motivo no e-mail — e os detalhes de cada campo
abaixo são o que separa uma importação limpa de cinquenta falhas descobertas depois do fato. O
casamento é **sensível a maiúsculas e acentos** em todos os campos.

🔴 **`Priority` é a armadilha mais provável de todas.** Só três valores são aceitos, **em
português**, escritos exatamente assim:

| No arquivo | Vira |
|---|---|
| `Alta` | `high` |
| `Média` | `medium` |
| `Baixa` | `low` |
| *(ausente)* | `medium` |

**`High`, `Normal` e `Low` — que é o que um gerador de BCF padrão emite — falham o tópico.** Não são
ignorados, não caem num valor padrão: derrubam o tópico inteiro, com `Priority "High" does not
exists` no e-mail. Um arquivo BCF gerado por qualquer ferramenta que não seja o próprio Construflow
tende a falhar **todos** os tópicos por causa disto. Se você gera o arquivo, traduza a prioridade
antes de exportar; se não gera, omita o campo (o que dá prioridade média) ou reescreva o arquivo.
`alta` minúsculo e `ALTA` também falham; espaço em volta do valor é tolerado.

⚠️ **`TopicStatus` é a exceção à regra "valor não reconhecido falha o tópico".** Situação
desconhecida **não** falha: o tópico entra **em silêncio como ativo**. Reconhecidos: `Ativo`,
`Active`, `active` → ativo; `Resolvido`, `Resolved`, `Closed`, `resolved` → resolvido; `Reprovado`,
`reproved` → reprovado. Repare nas ausências — `Reproved` (inglês, capitalizado) e `closed`
(minúsculo) **não** estão na lista e viram **ativo**, sem nenhum aviso no e-mail. Confira o resultado
depois de importar; o e-mail não vai contar.

⚠️ **`TopicType` casa pelo NÚMERO, não por sigla.** O formato é o que o próprio Construflow exporta:
o **id numérico** da categoria, um hífen e o nome — `03 - Interferência ou divergência entre
disciplinas`. Só o que vem **antes do hífen** é lido, e é lido como número: `3` e `03` sozinhos
também casam. **`ARQ - Arquitetura` não casa com nada** — `ARQ` não é número, e o tópico falha.
Campo ausente equivale a `00`.

⚠️ **`AssignedTo` aceita várias disciplinas, separadas por vírgula — e ignora e-mails em silêncio.**
`ARQ, EST` atribui as duas. **Qualquer entrada que contenha `@` é descartada sem aviso** — e é
exatamente ali que o padrão BCF manda pôr o e-mail do responsável. Consequência: um arquivo BCF
padrão, com `AssignedTo=joao@empresa.com`, entra **sem responsável explícito**; nesse caso o sistema
promove as disciplinas de `Labels` a responsáveis, e se também não houver `Labels` o tópico falha com
`Responsible discipline is required`. Abreviação que não existe no projeto (e não contém `@`) falha o
tópico.

⚠️ **`DueDate` só vira prazo quando o responsável veio de `AssignedTo`.** Quando a responsabilidade é
promovida a partir de `Labels`, o `DueDate` é **silenciosamente perdido** — e, como o prazo do
apontamento é derivado das disciplinas ([§11](#alteração-parcial-as-cinco-regras)), o apontamento
entra sem prazo.

⚠️ **Local (`Labels` com `^`) casa pelo CAMINHO inteiro, não pela última sigla.** O valor é a
sequência de abreviações da raiz até o nó, separada por `/`: `^TORRE/PAV2`, não `^PAV2`. Esse
caminho é o campo `abbreviationTree` de cada local, que vem no cadastro do projeto em
`project { locals }`, dentro de `flatten` ([§6](#a-forma-de-locals-e-labels)) — leia de lá, não monte
à mão. Sem espaço depois do `^` e sem espaço em volta da barra.

🔴 **Tópico sem nenhum local vira `^ND`.** Se **nenhum** label do tópico começa com `^`, o sistema
injeta `^ND` — e então exige que exista um local com abreviação `ND` no projeto. **Projeto nenhum
nasce com esse local.** Se você importa arquivos cujos tópicos nem sempre trazem local, **crie um
local `ND` no projeto antes**, ou todos esses tópicos falham com `Local "ND" does not exists`.

⚠️ **Categoria e fase passam por dois portões, e o primeiro é o catálogo GLOBAL.** O casamento é
contra o catálogo global da plataforma — nele, id e abreviação existem independentemente do projeto.
Só depois, ao gravar o apontamento, o projeto é consultado: valor que casou globalmente mas está
**inativo** neste projeto ainda **falha o tópico**, agora com `CATEGORY_MUST_BE_ACTIVE` /
`PHASE_MUST_BE_ACTIVE`. `project { categories { id name active } }` e `project { phases { … } }`
respondem aos dois portões de uma vez: eles trazem o catálogo global inteiro, anotado com o `active`
deste projeto ([§6](#catálogo-do-projeto--catálogo-global)).

Antes de uma importação grande, confira **as abreviações de disciplina e os caminhos de local** do
arquivo contra o cadastro do projeto, e **as categorias e fases** contra `project { categories }` /
`project { phases }`, filtrando por `active` ([§6](#6-descoberta)) — é o que evita descobrir
cinquenta falhas depois do fato.

### Armadilhas da importação

Além das acima, quatro falhas que não se anunciam:

- 🔴 **Subir o arquivo depois dos 200 segundos tem sucesso e não faz nada.** A URL vale quase sete
  dias, mas a janela de processamento é de 200 segundos: o `PUT` é aceito, o arquivo fica órfão, e
  **não há erro nem e-mail**. Ver [passo 2](#passo-2--subir-o-arquivo).
- 🔴 **Versão de BCF não suportada derruba a importação inteira** — não tópico a tópico. O e-mail
  traz um erro único, e nenhum apontamento é criado. Só **2.1** e **3.0** são aceitos.
- ⚠️ **Identificador de projeto não numérico na rota** não é validado na abertura. A importação
  **abre com sucesso** e morre depois, com um e-mail genérico que não diz que o problema era o
  projeto.
- ⚠️ **Comentário só é importado se o apontamento entrou.** Tópico que falhou leva os comentários
  dele junto — e o e-mail sinaliza a perda, mas não lista comentário por comentário. E **comentário
  vazio é descartado em silêncio**, sem aparecer no e-mail de forma nenhuma.

---

## 13. Checklist de integração

Recapitulação acionável. Cada item aponta a seção que explica o porquê.

### Antes da primeira chamada

- [ ] Criar um **usuário dedicado à integração**, com as permissões de projeto que ela precisa e nada
      além — a chave enxerga exatamente o que esse usuário enxerga. [§2](#o-que-a-credencial-enxerga)
- [ ] Emitir a Chave de Acesso e **guardar o segredo** — ele só é exibido uma vez.
      [§2](#emitir-a-chave-e-o-segredo)
- [ ] Enviar um **`User-Agent` identificável** em toda requisição. [§5](#dimensionamento)
- [ ] **Desligar o agrupamento automático de operações** do seu cliente GraphQL (`BatchHttpLink` e
      equivalentes). [§5](#forma-e-custo-da-consulta)
- [ ] Tratar `200` como **possível erro**: verificar `errors` antes de ler `data`, sempre — inclusive
      nas recusas de formato de filtro, que também vêm com `200`. [§4](#as-três-formas-de-recusa)
- [ ] Implementar a política de retry por status: `400` nunca; `429` pelo `Retry-After`; `5xx` com
      backoff e jitter. [§4](#o-que-pode-ser-retentado)
- [ ] Tratar `429` **pelo status e pelos cabeçalhos**, nunca pela forma do corpo — a recusa mais
      provável (a portaria) vem **sem** a lista `errors`. [§5](#o-envelope-de-recusa-por-taxa)
- [ ] Colocar um **limitador global de ~4,5 requisições por segundo** e no máximo **4 simultâneas**
      (2 a 4 processos em paralelo, nunca mais).
      [§5](#dimensionamento)

### Na carga inicial

- [ ] Enumerar os projetos por `loggedUserData { projects(permission: "view") }` — sem o argumento a
      lista traz só onde a credencial **escreve** — e **guardar o `status`**, porque ela inclui
      projeto inativo. [§6](#os-projetos-que-a-credencial-enxerga)
- [ ] Carregar o cadastro de cada projeto numa chamada só e guardá-lo do seu lado.
      [§6](#o-cadastro-do-projeto)
- [ ] Rodar as consultas do [passo 1](#passo-1--apontamentos-alterados) e do
      [passo 2](#passo-2--comentários-alterados) **sem janela de data**, projeto a projeto, paginando
      até o fim — mantendo o `includeDeleted: true` no passo 1, que é o que recupera as exclusões
      antigas que janela de data nenhuma alcança. [§7](#carga-inicial)
- [ ] Gravar por **`upsert` idempotente** — bordas de janela inclusivas repetem o item da borda.
      [§7](#a-janela-de-data)
- [ ] Saber que a carga **não** traz comentário de apontamento excluído — esse dado não existe pela
      API. [§7](#carga-inicial)

### Em cada rodada

- [ ] Reenumerar os projetos e **comparar o conjunto de identificadores com o da rodada anterior** —
      projeto que sumiu é perda de acesso, e não há outro sinal.
      [§6](#os-projetos-que-a-credencial-enxerga) · [§9](#o-que-você-não-detecta)
- [ ] Usar como janela **[início da rodada anterior, início desta rodada]** — nunca o **fim** da
      anterior, nunca "de agora para trás". [§7](#a-janela-de-data)
- [ ] Enviar as datas **com fuso explícito** (`Z` ou `-03:00`). [§7](#a-janela-de-data)
- [ ] Rodar **as duas consultas**, apontamentos e comentários. Comentário novo não move a data do
      apontamento. [§7](#por-que-são-duas-fontes-e-não-uma)
- [ ] Enviar **`filter.includeDeleted: true`** no passo 1 — sem ele a exclusão de apontamento é
      invisível, sem erro nenhum. [§10](#filterincludedeleted--pedir-os-excluídos)
- [ ] Buscar detalhe **apenas da união** dos dois, excluindo o que veio marcado como excluído — e
      **em lotes** por `filter.ids`, se você não espelha o histórico.
      [§7](#buscar-o-detalhe-em-lotes)
- [ ] Passar **`first` explícito**, ajustado à seleção — até 200 só com escalares, da ordem de 50 com
      relacionamentos expandidos —, e devolver o cursor **exatamente como veio**.
      [§5](#forma-e-custo-da-consulta) · [§10](#paginação-e-cursor)
- [ ] Iterar até `hasNextPage` ser `false` — a última chamada vazia é esperada.
      [§10](#paginação-e-cursor)
- [ ] Tratar **`deletedAt` preenchido como remoção** nas duas fontes — e ramificar por ele **antes**
      de ler qualquer outro campo do apontamento, que no excluído chega vazio.
      [§9](#o-que-você-detecta) · [§7](#passo-1--apontamentos-alterados)
- [ ] Manter a seleção enxuta: sem `notifications`, sem `comments` no detalhe, sem `viewpoints` e
      `bimPins` fora do fluxo BIM, e **nunca** `modelReferences`, que não existe na saída e derruba a
      consulta. [§8](#campos-a-evitar-e-por-quê)

### Periodicamente

- [ ] Reler o **cadastro de cada projeto** — uma vez por dia é folgado — e conferi-lo contra os
      vínculos guardados: etiqueta ou local apagado no projeto **não** gera sinal nos apontamentos.
      [§6](#frequência) · [§9](#o-que-você-não-detecta)
- [ ] Agendar a **recarga total por projeto** (mensal, por exemplo) e reconciliar por conjunto de
      identificadores. [§9](#a-recarga-total-periódica)
- [ ] Investigar todo projeto ativo cuja rodada voltou vazia — pode ser perda de acesso, que não
      produz erro. [§9](#o-que-você-não-detecta)

### Ao escrever

- [ ] Validar os identificadores de local, etiqueta e disciplina contra o cadastro do projeto, e os
      de categoria e fase contra `project { categories }` / `project { phases }` **filtrando por
      `active`**, antes de enviar.
      [§11](#criar-apontamento) · [§6](#catálogo-do-projeto--catálogo-global)
- [ ] Na criação, **sempre** enviar `locals` e `disciplines` não-vazios — omitir cria o apontamento e
      **depois** estoura, deixando um registro órfão. [§11](#criar-apontamento)
- [ ] Enviar **só os campos que mudaram** — campo omitido é preservado.
      [§11](#alteração-parcial-as-cinco-regras)
- [ ] **Nunca mandar `[]` querendo dizer "não mexer"**: apaga em etiquetas e referências, e é
      recusado em locais e disciplinas. [§11](#alteração-parcial-as-cinco-regras)
- [ ] Enviar `resolutionPhase` junto com `status` — o gatilho é **enviar**, não mudar — e manter
      `status` e `disciplines` **coerentes entre si**. [§11](#alteração-parcial-as-cinco-regras)
- [ ] Ao enviar `disciplines`: a lista é o **estado final**, e cada item precisa repetir `status`,
      `deadline` e `doneAt`, senão o prazo é zerado e a data de conclusão é sobrescrita.
      `remove: true` **não funciona** na alteração individual.
      [§11](#alteração-parcial-as-cinco-regras)
- [ ] Lembrar que o **prazo do apontamento é derivado das disciplinas** — enviar disciplinas sem
      prazo o apaga. [§11](#alteração-parcial-as-cinco-regras)
- [ ] No lote, **percorrer `rejectedIssues`**: a mutation devolve `200` sem `errors` mesmo com
      apontamentos recusados. [§11](#updateissues-é-uma-operação-parcial--leia-o-retorno)
- [ ] Ao editar comentário, **reenviar `mentionedUsers` sempre** — omitir apaga todas as menções.
      [§11](#criar-e-editar-comentário)
- [ ] Comparar `editedAt` antes de sobrescrever — não há trava, a última escrita vence.
      [§11](#não-atropelar-quem-está-editando)
- [ ] Imagens: URL assinada → `PUT` **sem cabeçalhos extras** → URL final **sem a query string** em
      `original` **e** em `markedUp` — sem `markedUp` não há miniatura.
      [§11](#imagens-em-três-passos)
- [ ] Para criar apontamento **com imagem**, usar `createIssue` individual: a criação em lote não
      processa imagens. [§11](#criação-em-lote-e-cópia--as-duas-restrições-que-surpreendem)
- [ ] Manter o **de-para** de situação, prioridade e visibilidade do seu lado — a API não traduz.
      [§8](#rótulos-que-a-api-não-traduz)

### Ao importar BCF

- [ ] Traduzir a **prioridade para português** (`Alta` / `Média` / `Baixa`) antes de exportar o
      arquivo — `High` / `Normal` / `Low` falham o tópico.
      [§12](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Emitir `TopicType` com o **número** da categoria antes do hífen (`03 - …`), não com sigla.
      [§12](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Emitir os locais como **caminho completo de abreviações** (`^TORRE/PAV2`), lido do
      `abbreviationTree` do cadastro. [§12](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Garantir que exista um local de abreviação **`ND`** no projeto, se algum tópico puder vir sem
      local. [§12](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Não contar com `AssignedTo` para e-mails: entrada com `@` é descartada em silêncio.
      [§12](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Enviar `commentVisibility` sempre que o arquivo puder conter comentários — e **conferir a
      visibilidade gravada**, que pode ter sido rebaixada.
      [§12](#passo-1--abrir-o-processo-de-importação)
- [ ] Subir o arquivo **imediatamente** após abrir o processo — a janela é de 200 segundos, e o `PUT`
      fora dela tem sucesso sem importar nada. [§12](#passo-2--subir-o-arquivo)
- [ ] Apontar a credencial para uma **caixa de e-mail monitorada**: o resultado só chega por lá.
      [§12](#passo-3--o-resultado)
- [ ] Conferir a situação dos tópicos depois de importar — `TopicStatus` desconhecido vira **ativo em
      silêncio**. [§12](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Tratar o Construflow como **espelho do arquivo** nos campos sobrescritos: situação das
      disciplinas, prazos e fase de resolução voltam ao que o arquivo diz.
      [§12](#comportamento-da-importação)
