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

> Texto cru para processamento por máquina: https://docs.construflow.com.br/construflow-api-documentation.md

Este documento descreve o contrato público suportado para integrar com o Construflow: autenticação, limites de uso, consultas, sincronização, escrita e importação de verificação de modelo. Use apenas as operações, os campos e os valores documentados aqui. Comportamentos observados fora desse contrato não são garantia de compatibilidade e podem mudar sem aviso.

[Consulte o changelog](/changelog) ou [receba atualizações por e-mail](https://materiais.construflow.com.br/avisos-de-atualizacoes-do-mcp-api).

---

## 1. Índice

- [API Construflow — documentação de integração](#)
  - [1. Índice](#1-índice)
  - [2. Introdução](#2-introdução)
    - [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)
  - [3. Autenticação](#3-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)
    - [Autenticação OAuth](#autenticação-oauth)
      - [Quando usar OAuth](#quando-usar-oauth)
      - [O servidor de autorização](#o-servidor-de-autorização)
      - [O que a permissão significa](#o-que-a-permissão-significa)
      - [Conectar um script pelo código](#conectar-um-script-pelo-código)
      - [Validade e renovação](#validade-e-renovação)
      - [Revogar](#revogar)
      - [Apps que agem em nome de quem os usa](#apps-que-agem-em-nome-de-quem-os-usa)
  - [4. Endereço e rotas](#4-endereço-e-rotas)
  - [5. Erros e recusas](#5-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)
    - [Avisos de obsolescência](#avisos-de-obsolescência)
  - [6. Limites de uso](#6-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)
  - [7. Descoberta](#7-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)
  - [8. Sincronização incremental](#8-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)
  - [9. Seleção de campos](#9-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)
  - [10. Exclusões e reconciliação](#10-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)
  - [11. Consultas e filtros](#11-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)
  - [12. Escrita](#12-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 — as duas restrições que surpreendem](#criação-em-lote--as-duas-restrições-que-surpreendem)
      - [Duas observações que evitam desenho errado](#duas-observações-que-evitam-desenho-errado)
    - [Evite sobrescritas concorrentes](#evite-sobrescritas-concorrentes)
    - [Criar e editar comentário](#criar-e-editar-comentário)
    - [Imagens em três passos](#imagens-em-três-passos)
  - [13. Importação de verificação de modelo (BCF)](#13-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)
  - [14. Checklist de integração](#14-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. Introdução

### 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 | [§7](#7-descoberta) |
| Ler o cadastro de cada projeto — disciplinas, situações de disciplina, locais, etiquetas, categorias, fases | [§7](#7-descoberta) |
| Manter uma cópia sincronizada de apontamentos e comentários, inclusive exclusões | [§8](#8-sincronização-incremental) |
| Consultar com o vocabulário completo de filtros | [§11](#11-consultas-e-filtros) |
| Criar e alterar apontamentos e comentários, anexar imagens | [§12](#12-escrita) |
| Importar uma verificação de modelo em BCF | [§13](#13-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 [§8](#8-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 ([§3](#emitir-a-chave-e-o-segredo)), e **`POST /bcf-server/bcf/import-process/{projectId}`**, a importação de verificação de modelo ([§13](#13-importação-de-verificação-de-modelo-bcf)).
- Binário nunca trafega pela API: imagens sobem por URL assinada, direto no armazenamento ([§12](#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 ([§7](#os-projetos-que-a-credencial-enxerga)) e o cadastro de cada projeto ([§7](#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 ([§11](#paginação-e-cursor)).
2. A janela de data — as bordas são inclusivas e o início errado perde edições ([§8](#a-janela-de-data)).
3. Exclusões — duas fontes independentes, e um conjunto de mudanças que **não** produz sinal nenhum ([§10](#10-exclusões-e-reconciliação)).
4. Os limites de uso e o contrato de recusa ([§6](#6-limites-de-uso)).
5. As regras da alteração parcial na escrita, onde lista vazia apaga em uns campos e é recusada em outros ([§12](#alteração-parcial-as-cinco-regras)).

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

---

## 3. Autenticação

A API aceita duas formas de autenticação, e a escolha depende de quem chama:

- **Chave de Acesso** (HTTP Basic Auth), descrita nesta seção: para integração de servidor e automação. ⚠️ **Deixa de ser aceita em 15/10/2026 — migre para [OAuth](#autenticação-oauth).**
- **OAuth** (Bearer): para o script que a pessoa conecta à própria conta com um código e para o app que age em nome de quem o usa. Está em [Autenticação OAuth](#autenticação-oauth).

Com a Chave de Acesso, toda requisição leva o cabeçalho:

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

### Emitir a chave e o segredo

⚠️ **A Chave de Acesso deixa de ser aceita em 15/10/2026 — migre para [OAuth](#autenticação-oauth).** Até lá ela funciona como descrito abaixo.

Você emite a credencial pela interface do Construflow:

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.

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,
  "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 ([§7](#os-projetos-que-a-credencial-enxerga)).

⚠️ **O `email` de um usuário pode mudar, e o endereço antigo pode passar a outro usuário.** O `id` e o `guid` continuam os mesmos. Use o `id` ou o `guid` como chave do usuário na sua base, nunca o `email`. Quando o endereço novo já era de outro usuário, os dois se unem: o que o outro criou passa a ter o `id` e o `guid` do usuário que trocou o e-mail, e os apontamentos e comentários voltam na sincronização incremental ([§8](#8-sincronização-incremental)). O outro continua existindo, sem projetos.

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 credencial opera com as permissões do usuário que a emitiu. Tudo o que esse usuário puder ler ou alterar nos projetos pode ficar ao alcance da integração.

**Crie um usuário dedicado para cada integração**, conceda somente os projetos e as permissões necessários e revise esse acesso periodicamente. Não use a conta pessoal de um colaborador nem uma conta administrativa: o vazamento da chave teria o mesmo alcance dessa conta.

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 |

🔴 **Trate a chave e o segredo como credenciais de produção.** Não grave em código, repositório, log, mensagem ou aplicação cliente. Em caso de exposição, interrompa a integração, revogue a chave — a chave revogada para de valer em até 1 minuto —, emita outra credencial e investigue as chamadas realizadas com a chave comprometida.

⚠️ **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 compartilham os mesmos limites ([§6](#6-limites-de-uso)). Para separar cotas é preciso separar usuários.

### 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`. Por isso o teste de credencial acima usa a rota REST, e todo cliente GraphQL deve verificar `errors` antes de ler `data`.

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.

### Autenticação OAuth

Além da Chave de Acesso, a API aceita tokens OAuth 2.0. Com eles, uma pessoa conecta um script à própria conta digitando um código, sem emitir chave nenhuma, e um app age em nome de cada pessoa que o usa. A requisição leva o token no cabeçalho:

```
Authorization: Bearer <access_token>
```

#### Quando usar OAuth

| Chave de Acesso (Basic) | OAuth (Bearer) |
|---|---|
| Integração de servidor e automação | Script que a pessoa conecta à própria conta com um código |
| Credencial de um usuário dedicado, emitida em **Configurações → Chaves de acesso** ([§3](#emitir-a-chave-e-o-segredo)) | App que age em nome de quem o usa, como um assistente de IA |
| **Deixa de ser aceita em 15/10/2026** — migre para OAuth | Token de 15 minutos, renovado pelo próprio script ou app ([Validade e renovação](#validade-e-renovação)) |

As duas formas chamam o mesmo host (`https://api.construflow.com.br`), as mesmas rotas de [§4](#4-endereço-e-rotas), com os limites de [§6](#6-limites-de-uso), contados em separado para a Chave e para o OAuth ([Como os limites se combinam](#como-os-limites-se-combinam)). Só o cabeçalho de autenticação muda.

#### O servidor de autorização

Os tokens OAuth são emitidos pelo servidor de autorização do Construflow, cujo identificador (`issuer`) é `https://auth.construflow.com.br`. Os endereços dele estão no documento de descoberta:

```
https://auth.construflow.com.br/.well-known/openid-configuration
```

O mesmo documento responde também em `https://auth.construflow.com.br/.well-known/oauth-authorization-server`. **Leia os endereços de lá, em vez de montá-los à mão.** Os exemplos deste documento trazem os valores atuais só para ficarem legíveis.

| Campo do documento de descoberta | Para quê |
|---|---|
| `device_authorization_endpoint` | Pedir o código que conecta um script ([Conectar um script pelo código](#conectar-um-script-pelo-código)) |
| `token_endpoint` | Receber os tokens e renová-los ([Validade e renovação](#validade-e-renovação)) |
| `revocation_endpoint` | Revogar o acesso ([Revogar](#revogar)) |
| `registration_endpoint` | Registrar um app automaticamente ([Apps que agem em nome de quem os usa](#apps-que-agem-em-nome-de-quem-os-usa)) |
| `authorization_endpoint` | Levar a pessoa à tela de permissão, no fluxo de app |
| `jwks_uri` | As chaves públicas que assinam os tokens |

O único recurso aberto a integradores é **`https://api.construflow.com.br`**, escrito exatamente assim, sem barra no fim: é um identificador, não um endereço a chamar. O escopo é **`construflow`**.

O access token é um JWT de 15 minutos, emitido para esse recurso (`aud`). **Trate-o como opaco**: use o `expires_in` da resposta para saber quando renovar e não decodifique o token para decidir nada.

#### O que a permissão significa

Conectar um script ou app é dar a ele **acesso total e irrestrito** à conta: ele passa a fazer tudo o que a pessoa pode fazer no Construflow, com as permissões dela — ver, criar, alterar e excluir. **Não há acesso parcial.**

Antes de permitir, a pessoa vê numa tela do Construflow:

- o nome do app ou script;
- o selo **verificado** ou **não verificado pelo Construflow**;
- o endereço para onde o app leva a resposta;
- a conta que está concedendo o acesso;
- o aviso de acesso total e irrestrito.

App desconhecido funciona do mesmo jeito, no mesmo dia, marcado como não verificado. A permissão dada a um app fica lembrada por conta e app enquanto o nome dele e o endereço de retorno não mudarem. O script conectado por código pede a permissão a cada aprovação.

A pessoa revoga os acessos em **Configurações → Aplicativos conectados**; cada script aprovado é uma linha própria, revogável sozinha. **Sair de todos os dispositivos**, a troca e a redefinição de senha e a troca do e-mail do usuário encerram todos os acessos OAuth de uma vez; a Chave de Acesso continua valendo.

Criar Chave de Acesso e trocar senha só se faz de dentro do Construflow: um token OAuth recebe recusa nessas ações.

⚠️ **Para script de integração, vale a regra da Chave de Acesso: conecte com um usuário dedicado**, com só os projetos e as permissões necessários ([O que a credencial enxerga](#o-que-a-credencial-enxerga)). O script alcança exatamente o que a conta que o aprovou alcança.

#### Conectar um script pelo código

O script usa o `client_id` público **`scripts`**, que não tem segredo: não há nada para emitir nem guardar além dos tokens. A conexão tem quatro passos:

1. O script pede um código.
2. O script mostra à pessoa o endereço e o código que vieram na resposta.
3. A pessoa abre o endereço, entra na conta, confere que o código na tela é o mesmo do script e permite.
4. Enquanto isso, o script consulta o servidor de autorização até receber os tokens.

O código tem 8 caracteres no formato `XXXX-XXXX` e vale por 10 minutos (`expires_in`). A consulta se repete a cada `interval` segundos (5). Cada aprovação é um acesso próprio, com uma linha própria em **Aplicativos conectados**.

Pedir o código:

```bash
curl -s https://auth.construflow.com.br/oauth/device/auth \
  -d client_id=scripts \
  -d scope=construflow
```

```json
{
  "device_code": "…",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://auth.construflow.com.br/oauth/device",
  "verification_uri_complete": "https://auth.construflow.com.br/oauth/device?user_code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}
```

Mostre à pessoa o `verification_uri_complete` — ou o `verification_uri` e o `user_code` — exatamente como vieram. Não monte o endereço à mão.

Consultar até receber os tokens:

```bash
curl -s https://auth.construflow.com.br/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
  -d device_code="$DEVICE_CODE" \
  -d client_id=scripts
```

```json
{
  "access_token": "eyJ…",
  "expires_in": 900,
  "token_type": "Bearer",
  "refresh_token": "…",
  "scope": "construflow"
}
```

Enquanto a conexão não se resolve, a consulta responde `400` com o corpo `{ "error": "<código>" }`:

| `error` | O que significa | O que fazer |
|---|---|---|
| `authorization_pending` | A pessoa ainda não decidiu | Consulte de novo depois de `interval` segundos |
| `slow_down` | A consulta está rápida demais | Some 5 segundos ao intervalo e continue |
| `access_denied` | A pessoa cancelou | Pare. Para tentar de novo, peça outro código |
| `expired_token` | O código venceu | Peça outro código |

O fluxo completo em Python, com `requests`:

```python
import time
import requests

AS = "https://auth.construflow.com.br"
API = "https://api.construflow.com.br"
UA = {"User-Agent": "MeuScript/1.0 (contato@empresa.com)"}


def conectar():
    r = requests.post(f"{AS}/oauth/device/auth",
                      data={"client_id": "scripts", "scope": "construflow"},
                      headers=UA, timeout=30)
    r.raise_for_status()
    pedido = r.json()
    print(f"Abra {pedido['verification_uri_complete']} e confira o código {pedido['user_code']}.")
    intervalo = pedido["interval"]
    prazo = time.monotonic() + pedido["expires_in"]
    while time.monotonic() < prazo:
        time.sleep(intervalo)
        r = requests.post(f"{AS}/oauth/token", data={
            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
            "device_code": pedido["device_code"],
            "client_id": "scripts",
        }, headers=UA, timeout=30)
        if r.ok:
            return r.json()
        erro = r.json().get("error")
        if erro == "authorization_pending":
            continue
        if erro == "slow_down":
            intervalo += 5
            continue
        raise RuntimeError(f"conexão recusada: {erro}")
    raise RuntimeError("o código venceu; peça outro")


def renovar(refresh_token):
    r = requests.post(f"{AS}/oauth/token", data={
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": "scripts",
    }, headers=UA, timeout=30)
    r.raise_for_status()
    return r.json()  # traz um refresh_token NOVO: grave-o antes de usar o access_token


tokens = conectar()
me = requests.get(f"{API}/identity/users/me",
                  headers={**UA, "Authorization": f"Bearer {tokens['access_token']}"},
                  timeout=30)
print(me.json()["email"])
```

Com o token em mãos, a chamada à API leva `Authorization: Bearer <access_token>` no lugar do Basic, e o teste de [§3](#emitir-a-chave-e-o-segredo) vale do mesmo jeito:

```bash
curl https://api.construflow.com.br/identity/users/me \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "User-Agent: MeuScript/1.0 (contato@empresa.com)"
```

#### Validade e renovação

O access token vale 15 minutos (`expires_in`). Renove antes de vencer, ou ao receber `401` numa rota REST ou o erro `UNAUTHENTICATED` no `POST /graphql`, que chega com status `200` ([Quando a credencial não é aceita](#quando-a-credencial-não-é-aceita)):

```bash
curl -s https://auth.construflow.com.br/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" \
  -d client_id=scripts
```

A renovação devolve **sempre um refresh token novo**. O anterior continua aceito até o novo ser usado, e quem o reapresenta recebe o mesmo refresh token novo. Um refresh token mais velho que isso recebe `400` com `invalid_grant`, e o acesso continua valendo para quem tem o refresh token atual.

⚠️ **Renove num lugar só.** Deixe um processo só encarregado de renovar, e grave o refresh token novo **antes** de usar o access token novo.

O acesso dura enquanto for usado: cada renovação o estende por 30 dias. Depois de 30 dias sem renovar, é preciso pedir um código novo.

Refresh recusado — `400` com `invalid_grant` — pede que você releia o refresh token gravado. Se outro processo gravou um mais novo, use-o; se não, o acesso foi revogado — inclusive pela troca do e-mail do usuário — ou venceu: conecte de novo, com um código novo. Não tente de novo com o mesmo refresh token.

#### Revogar

O script revoga o próprio acesso com o refresh token — ao ser desinstalado, ao trocar de máquina ou quando não precisar mais dele:

```bash
curl -s https://auth.construflow.com.br/oauth/token/revocation \
  -d token="$REFRESH_TOKEN" \
  -d token_type_hint=refresh_token \
  -d client_id=scripts
```

A pessoa também revoga em **Configurações → Aplicativos conectados** ([O que a permissão significa](#o-que-a-permissão-significa)). Nos dois casos, o access token já emitido vale até vencer, no máximo 15 minutos; depois disso, nem ele nem a renovação funcionam.

#### Apps que agem em nome de quem os usa

Esta parte é para quem constrói um app usado por várias pessoas — um assistente de IA, uma ferramenta web, um app de desktop —, em que cada pessoa conecta a própria conta. O app usa o fluxo de código de autorização (authorization code), com:

- PKCE obrigatório, só com o método `S256`;
- `scope=construflow`;
- `resource=https://api.construflow.com.br`.

O app se identifica de uma de duas formas, sem cadastro manual no Construflow.

**Registro automático** (RFC 7591). O app faz um `POST` JSON em `registration_endpoint`, e a resposta traz o `client_id` dele:

```bash
curl -s https://auth.construflow.com.br/oauth/reg \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Meu App",
    "redirect_uris": ["https://meuapp.com.br/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none"
  }'
```

**Documento de metadados** (Client ID Metadata Document). O `client_id` do app é a URL `https` de um documento JSON publicado pelo próprio app, com o mesmo `client_id`, o `client_name` e as `redirect_uris`:

```json
{
  "client_id": "https://meuapp.com.br/oauth/client.json",
  "client_name": "Meu App",
  "redirect_uris": ["https://meuapp.com.br/callback"]
}
```

O documento é servido na porta 443, por um host com endereço público, e responde em até 5 segundos com até 5 kB. O cache dele respeita o `Cache-Control` da resposta.

O endereço de retorno segue as mesmas regras nas duas formas:

- `https` em qualquer host;
- `http` só em loopback (`localhost`, `127.0.0.1`, `[::1]`), em qualquer porta;
- esquema próprio (`meuapp://…`) só com `application_type: "native"`;
- sem curinga.

O `redirect_uri` de cada pedido tem de ser exatamente um dos registrados; se não for, a recusa vem antes de qualquer tela. A tela de permissão aparece na primeira vez de cada pessoa e de novo se o nome do app ou o endereço de retorno mudarem.

⚠️ **Client de registro automático que passa 90 dias sem nenhum acesso é apagado.** Ao receber `invalid_client`, registre o app de novo.

Pedido com recurso diferente de `https://api.construflow.com.br` é recusado com `invalid_target`.

Os tokens do app seguem as regras de [Revogar](#revogar), com o `client_id` do próprio app no lugar de `scripts`, e o acesso dura enquanto for usado: cada renovação o estende por 30 dias. O app sem segredo — `token_endpoint_auth_method` `none`, o padrão quando o campo falta — renova como em [Validade e renovação](#validade-e-renovação). No app que se autentica com segredo ou chave, o refresh token já trocado, se reapresentado, revoga o acesso, e a pessoa precisa conectar a conta de novo.

---

## 4. Endereço e rotas

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

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

O servidor de autorização, que emite os tokens OAuth, fica em `https://auth.construflow.com.br`, e as rotas dele estão em [Autenticação OAuth](#autenticação-oauth).

Este documento é a referência do esquema público suportado. Não dependa de introspecção nem de campos encontrados por tentativa: use somente as operações e seleções documentadas aqui.

---

## 5. 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` ([§3](#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 limite | `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 cinco, 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` | [§6](#forma-e-custo-da-consulta) |
| `PAGE_SIZE_EXCEEDED` | `first` acima de 200 | `code`, `reason`, `limit`, `observed` | [§6](#forma-e-custo-da-consulta) |
| `QUERY_COST_EXCEEDED` | A consulta instancia objetos demais | `code`, `reason`, `limit`, `observed` | [§6](#forma-e-custo-da-consulta) |
| `BATCHING_NOT_SUPPORTED` | Você enviou um array de operações numa chamada só | **só `code`** | [§6](#forma-e-custo-da-consulta) |
| `UPLOAD_REQUEST_INVALID` | O corpo `multipart` não pôde ser lido | **só `code`** | [§6](#a-tabela-de-limites) |

⚠️ **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 [§11](#erros-de-formato).

### A quarta forma: recusa na borda

⚠️ **Corpo grande demais pode devolver `403`, não `413`.** A resposta não segue o envelope GraphQL e pode não trazer `errors`, `extensions` nem cabeçalhos de limite. Se você recebeu um `403` numa requisição volumosa, meça o corpo e reduza-o antes de tentar novamente.

Binário não deve ir embutido na requisição. Use a URL assinada ([§12](#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 ([§6](#o-envelope-de-recusa-por-taxa)) |
| `5xx` | Sim | Backoff exponencial `2^n` segundos, com jitter. Nunca em laço imediato |

### Avisos de obsolescência

A resposta a uma chamada que usa um item obsoleto traz:

- `Deprecation: @<segundos Unix>` — desde quando o item está obsoleto ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745.html));
- `Sunset: <data HTTP>` — a data-limite, só quando existe, nunca antes da data do `Deprecation` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html));
- `Link: <entrada do changelog>; rel="deprecation"; type="text/html"` — um por item, apontando para a entrada daquele item, que diz o que está obsoleto e como migrar;
- `Warning: 299 - "<frase>"` — uma frase curta por item, com o que está obsoleto e o que usar no lugar.

Com mais de um item na mesma resposta, `Deprecation` traz a data mais antiga, `Sunset` a mais próxima, e `Link` e `Warning` se repetem, um por item. A chamada funciona igual até a data-limite; a integração deve registrar esses cabeçalhos e tratá-los como alerta.

---

## 6. Limites de uso

Os limites abaixo são parte do contrato da API, não uma meta de consumo. **Dimensione abaixo dos tetos, limite o paralelismo e trate toda recusa.** Requisições acima da faixa são rejeitadas; padrões persistentes que prejudiquem a plataforma podem levar a restrições adicionais para proteger os demais clientes.

Uma rodada parcialmente recusada não é uma rodada concluída. Registre falhas, respeite o tempo de espera informado e reconcilie o resultado antes de avançar o marcador da sincronização.

### A tabela de limites

| Limite | Valor | Ao exceder |
|---|---|---|
| **Volume total por identidade**, em cada contagem (**toda** requisição autenticada) | **40 / 10 s** e **80 / 60 s** | `429` `user-envelope` |
| 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 |
| Arquivo enviado por `multipart` (campos `Upload`) | **10 MB** cada | `413` `UPLOAD_FILE_SIZE_EXCEEDED` |
| Arquivos por chamada em `multipart` | **500** | `413` `UPLOAD_FILE_COUNT_EXCEEDED` |

Os limites valem igual para a Chave de Acesso e para o token OAuth de script ou de app ([Autenticação OAuth](#autenticação-oauth)), cada um na contagem dele ([Como os limites se combinam](#como-os-limites-se-combinam)).

### Como os limites se combinam

**As janelas de 10 e de 60 segundos valem ao mesmo tempo**, e basta uma delas estourar para a requisição ser recusada. O limite (`user-envelope`) conta **toda** requisição autenticada: consultas, alterações, emissão de URL assinada, importação de BCF e as demais rotas da plataforma. **Não há recorte por tipo de operação** — uma consulta e uma mutation pesam igual na mesma conta, e é essa conta única que a integração precisa dimensionar.

**As cotas são agregadas por identidade, com uma contagem para cada forma de acesso.** A mesma pessoa gasta uma contagem com a Chave de Acesso e outra, independente, com o OAuth: esgotar uma não recusa a outra. Dentro do OAuth, a contagem depende de quem chama:

| Quem chama pelo OAuth | Contagem |
|---|---|
| App **verificado pelo Construflow** ([O que a permissão significa](#o-que-a-permissão-significa)) | uma só dele, por pessoa |
| Script conectado pelo código e app **não verificado** | uma só, dividida entre todos eles, por pessoa |

Todas as contagens seguem a [tabela de limites](#a-tabela-de-limites). Duas chaves do mesmo usuário continuam dividindo a contagem da Chave, dois scripts da mesma pessoa continuam dividindo a dos não verificados, e chamadas distribuídas em várias máquinas não mudam nada. Use identidades separadas apenas quando elas representarem integrações realmente independentes, cada uma com o seu usuário dedicado e o seu menor conjunto de permissões.

**Emissão de URL assinada entra no volume total como qualquer outra chamada.** Peça as URLs do gesto inteiro em uma chamada (até 50 nomes), sob demanda. Não abra uma chamada paralela por arquivo.

### 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`), que continuam sendo a forma recomendada de escrever muitos apontamentos: elas são **uma** operação com muitos itens ([§12](#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. Consulta acima do teto é recusada e deve ser simplificada, nunca repetida sem alteração.

**Página máxima de 200.** Passe `first` sempre de forma explícita, em toda consulta paginada. Não dependa do valor padrão: sem `first`, a resposta pode ser maior que a página recomendada e consumir mais objetos do que a integração dimensionou. Use até 200 somente para seleções escalares e reduza a página quando expandir relacionamentos.

**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 ([§9](#deixe-a-consulta-resolver-os-nomes)).

### O envelope de recusa por taxa

Toda recusa por taxa traz o status `429`, os mesmos sete cabeçalhos e um corpo **plano** — os cinco campos na raiz, sem a lista `errors`:

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

```json
{
  "message": "You have reached the request rate limit",
  "code": "REQUEST_RATE_EXCEEDED",
  "reason": "user-envelope",
  "retryAfter": 12,
  "policy": "\"burst\";q=40;w=10, \"sustained\";q=80;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 limite 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** limite estourou, e os dois pedem correções diferentes: `user-envelope` pede **ritmo** menor; `credential-concurrency`, **menos chamadas abertas ao mesmo tempo**. Reduzir o ritmo não resolve recusa por simultaneidade.
- Os mesmos campos também vêm no corpo (`reason`, `retryAfter`, `policy`). Mesmo assim, trate o cabeçalho `Retry-After` como a referência para o tempo de espera.

🔴 **Integrações devem rodar no servidor, nunca no navegador.** Não exponha chave e segredo em JavaScript, aplicativo distribuído ao usuário ou qualquer ambiente em que a credencial possa ser inspecionada.

Os valores documentados de `X-RateLimit-Reason` são:

| Razão | O que estourou |
|---|---|
| `user-envelope` | volume total da sua identidade, na contagem da forma de acesso usada |
| `credential-concurrency` | trabalho **simultâneo** da sua identidade |

O corpo da recusa por simultaneidade tem a mesma forma, com os valores daquele limite:

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

⚠️ **A recusa por taxa não vem no envelope GraphQL.** Ela é aplicada antes de a requisição chegar ao GraphQL, então **não há `errors` nem `extensions`** no corpo — nem quando a chamada recusada era um `POST /graphql`.

**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 recusa nenhuma e entra em laço.

O teto de simultâneas conta **requisições em voo**, não por minuto. Mantenha no máximo 2 requisições abertas ao mesmo tempo e trate `credential-concurrency` mesmo assim: tempo de resposta e outras chamadas da mesma identidade podem consumir parte da margem.

### Dimensionamento

**Dimensione pela taxa, e mantenha o paralelismo baixo.** O teto sustentado é de 80 requisições por minuto — cerca de 1,3 por segundo — e vale para tudo o que a integração faz, leitura e escrita na mesma conta. Use um limitador global de **até ~1 requisição por segundo** para toda a integração, com a margem servindo para a rajada de 10 segundos, e reduza o ritmo quando receber `429`.

Na prática isso é **1 processo** (ou conexão) trabalhando em sequência, no máximo 2: com duas 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.

**Nesta taxa, ler item a item não escala.** Dez mil apontamentos lidos um a um levam mais de duas horas; a listagem paginada traz os mesmos campos em dezenas de chamadas ([§11](#11-consultas-e-filtros)), e o detalhe vem em lotes por `filter.ids` ([§8](#custo-por-rodada)). Desenhe a rotina pela listagem e reserve a leitura unitária para o que ela não entrega.

Paralelismo alto não acelera nada aqui: acima da faixa recomendada, o que você ganha em disparo perde em recusa, e a rajada 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 ([§8](#8-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 ([§7](#7-descoberta)).
5. **Identifique-se.** Envie sempre um `User-Agent` descritivo — `MinhaIntegracao/1.2 (contato@empresa.com)`. Isso permite localizar rapidamente o responsável em suporte, incidentes e investigações de tráfego.

---

## 7. 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 ([§3](#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 vocabulário suportado é `participate`, `view`, `manage` e `invites`. **Valide o valor antes de enviar**: um valor fora do contrato pode produzir uma lista vazia sem explicar a causa. Se a descoberta voltou vazia, confira primeiro a grafia e as permissões do usuário dedicado.

⚠️ **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.

⚠️ **Projetos de acesso público dos quais a credencial não é membro não são enumerados por esta consulta.** Se a integração precisa operar num projeto, vincule o usuário dedicado explicitamente e não dependa de acesso público ou de um identificador obtido por fora da descoberta.

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

⚠️ **A lista é o recorte atual da credencial.** Guarde o conjunto de identificadores de uma rodada para a outra e compare. Um projeto que desapareceu deve ser tratado como mudança de acesso e exige reconciliação; não continue publicando a cópia anterior como se ela ainda estivesse autorizada ([§10](#10-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 ([§13](#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 ([§12](#12-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 ([§9](#9-seleção-de-campos)). O cadastro serve para outro propósito: preencher um seletor na sua interface e validar um valor **antes** de escrever ([§12](#12-escrita)).

---

## 8. 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"`) | **Não use** — o resultado depende do ambiente e não faz parte do contrato recomendado |
| Bordas | **Inclusivas nos dois lados** |
| Janela invertida (`From` > `To`) | Devolve **lista vazia, sem erro** |
| Valor inválido | Fora do contrato; pode deixar o filtro sem efeito e ampliar a consulta |

⚠️ **Envie sempre o fuso explícito** — `Z` ou `-03:00`. É a única forma que significa a mesma coisa dos dois lados. 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`.

🔴 **Valide a data localmente antes de cada rodada.** Uma data fora do contrato pode deixar o filtro sem efeito e transformar a sincronização incremental numa **varredura total do projeto**. Se a rodada começou a trazer o acervo inteiro, interrompa-a e confira as datas antes de avançar o marcador.

⚠️ **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 ([§6](#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 [§11](#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 ([§11](#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 ([§6](#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 [§9](#9-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 ([§9](#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 ([§6](#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** ([§11](#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 ([§10](#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 [§10](#10-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 ([§7](#frequência)).

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

| Estratégia do passo 3 | Chamadas | A ~1 req/s |
|---|---|---|
| Um a um | `1 + 100 + 800 = 901` | cerca de **15 minutos** |
| Em lotes de 50 | `1 + 100 + 16 = 117` | cerca de **2 minutos** |

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 ([§11](#paginação-e-cursor)). É esperado, e o laço converge.

---

## 9. 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 ([§6](#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` | ⚠️ Tem custo alto em listagens. Se você precisa do agregado, use `notificationsSummary { count mentions last }` |
| `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 ([§8](#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. 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 devolve o nome atual junto da relação. Peça esses nomes onde precisar deles, mas ajuste o tamanho da página ao número de objetos selecionados.

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 ([§12](#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 `email` não é chave de usuário.** Ele pode mudar, e o endereço antigo pode passar a outro usuário; o `id` e o `guid` continuam os mesmos. Case o usuário pelo `id`, nunca pelo `email`, e releia o `email` como relê o nome. Quando o endereço novo já era de outro usuário, os dois se unem: o que o outro criou passa a ter o `id` do usuário que trocou o e-mail, e os apontamentos e comentários chegam pela sincronização incremental com o autor novo ([§8](#8-sincronização-incremental)).

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 [§7](#7-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 [§7](#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` ([§7](#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 ([§12](#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.

---

## 10. 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 | ✅ | [§9 passo 1](#passo-1--apontamentos-alterados) — cai na janela de data |
| Apontamento **excluído** | ✅ **só se você pedir** | [§9 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 | ✅ | [§9 passo 2](#passo-2--comentários-alterados) |
| Comentário **excluído** | ✅ | [§9 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 [§7](#7-descoberta) |

### O que você NÃO detecta

| O que aconteceu | Detecta? | Por que não |
|---|---|---|
| **Etiqueta ou local apagado no projeto** | ❌ | Essa mudança não coloca os apontamentos vinculados na janela incremental. Releia o cadastro do projeto periodicamente e reconcilie os vínculos |
| Comentário de apontamento excluído | ❌ | A listagem de comentários não devolve comentários de apontamentos excluídos. Use o aviso de exclusão do próprio apontamento para encerrar o registro local |
| **Conteúdo** de um apontamento excluído | ❌ | A API devolve somente o aviso de exclusão. Preserve na sua cópia todo conteúdo que precise continuar disponível após a exclusão |
| Mudança de **visibilidade** ou de **grupo** que tira o apontamento do seu acesso | ❌ | O apontamento deixa de aparecer. Não trate ausência numa janela incremental, isoladamente, como autorização para conservar ou redistribuir a cópia anterior |
| **Perda de acesso ao projeto** | ⚠️ parcial | O projeto pode desaparecer da descoberta ou responder erro de permissão. Compare a lista de projetos entre rodadas e investigue toda rodada vazia de um projeto que ainda parecia ativo |

⚠️ **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 ([§7](#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 ([§6](#dimensionamento)) — a recarga é pesada, mas não é urgente.

---

## 11. Consultas e filtros

Referência completa da leitura. A sincronização de [§8](#8-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 ([§6](#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 ([§6](#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 |

**Trate o cursor como valor opaco e devolva-o exatamente como veio.** Cursor alterado ou fora de formato falha com `INVALID_PAGINATION_CURSOR`. Valide também `first` do seu lado como inteiro entre 1 e 200; valores fora desse contrato não são suportados.

⚠️ **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` |

🔴 **A tabela acima é a lista fechada de filtros suportados.** Valide as chaves e os tipos antes de enviar. Uma chave desconhecida ou escrita incorretamente pode não produzir uma recusa clara e pode transformar o que deveria ser um recorte numa varredura muito maior. Uma resposta `200` não prova que o filtro foi aplicado.

Cada listagem tem o seu próprio contrato. `includeDeleted`, por exemplo, pertence à listagem de apontamentos e não deve ser enviado à [listagem de comentários do projeto](#comentários-do-projeto), onde os comentários excluídos já fazem parte do retorno.

> **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 ou `null`)* | Todos os apontamentos visíveis ao usuário |

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. Não envie valores fora da tabela: valide o enum do seu lado para impedir que um erro de grafia amplie a resposta.

⚠️ **`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 ([§9](#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`

Envie um `Int`. Omitir a chave ou enviar `null` significa não aplicar esse filtro. Não dependa de coerção de strings nem envie valores fora do tipo documentado.

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

⚠️ Este filtro é estrito. Envie uma lista de inteiros positivos. Item inválido ou valor que não seja lista recusa a consulta inteira com `INVALID_ISSUES_IDS_FILTER`; nenhum apontamento é devolvido.

- ⚠️ **`[]` significa conjunto vazio, não "sem filtro".** Devolve **zero** apontamentos — o oposto de `disciplineIds: []`, que desliga o filtro. 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.** **Indexe pelo `id` de cada item e ordene do seu lado.** O cursor continua sendo a única referência para avançar a paginação.
- 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**; duplicados são deduplicados. Valide a lista antes de enviar e não dependa de coerção ou tolerância a itens inválidos.
- **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 [§7](#7-descoberta).
- Id que não pertence ao projeto, ou inexistente, não casa nenhum apontamento.
- `[]`, `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 não faz parte do contrato recomendado. Detalhe em [§8](#a-janela-de-data).
- `updatedAtFrom` maior que `updatedAtTo` devolve **lista vazia, sem erro**.
- Valide a data antes de enviar. Um valor fora do contrato pode não produzir uma recusa clara e pode transformar a consulta numa 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 ([§8](#passo-1--apontamentos-alterados)).

- **Ligue o filtro somente com o booleano `true`.** Não use strings ou números. Um valor fora do tipo documentado pode deixar a detecção de exclusão inativa sem produzir uma recusa clara.
- **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 [§9 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

**Valide filtros, cursores e paginação antes de chamar a API.** Não use uma resposta bem-sucedida como validação do input: algumas entradas fora do contrato podem ser ignoradas, ampliar o recorte ou produzir erro genérico.

Duas recusas têm código próprio e podem ser reconhecidas 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 pode vir nulo enquanto `data` continua presente. Verifique `errors` antes de ler `data`; o código fica em `errors[0].extensions.code` e a mensagem em `errors[0].message`. O mesmo contrato de cursor vale para a [listagem de comentários do projeto](#comentários-do-projeto).

Outras entradas malformadas podem responder `INTERNAL_SERVER_ERROR` sem código específico. Não escreva tratamento baseado na mensagem interna: corrija o input e valide localmente os tipos da tabela de contrato. Profundidade, tamanho de página, preço e array de operações têm recusas próprias em `400` ([§5](#as-três-formas-de-recusa), [§6](#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 ([§9](#campos-a-evitar-e-por-quê)). A seleção completa recomendada está em [§9 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 ([§8](#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]`. Não tente distinguir os dois casos por esta consulta.
- 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 ([§9 passo 1](#passo-1--apontamentos-alterados)).

> ⚠️ Trate `NOT_FOUND` como resposta final. Não aplique retry a um identificador inexistente ou excluído.

### 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 ([§8](#passo-2--comentários-alterados)).

**Argumentos:**

| Argumento | Contrato |
|---|---|
| `first: Int` | Tamanho da página. **Máximo 200**, e ajustado à seleção ([§6](#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 ([§9 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.

---

## 12. 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 ([§5](#5-erros-e-recusas)).

### 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`.

`title` aceita até **255 caracteres**, na criação e na alteração; acima disso o apontamento é recusado com `ISSUE_TITLE_TOO_LONG` (400). O título de imagem (`IssueImageInput.title`) segue o mesmo teto, mas é truncado em vez de recusado.

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

| Argumento | Quando é exigido | O que acontece se faltar |
|---|---|---|
| `locals` | **sempre** | recusa `ISSUE_MUST_HAVE_LOCALS` |
| `disciplines` | **sempre** | recusa `ISSUE_MUST_HAVE_DISCIPLINES` |
| `resolutionPhase` | **sempre que `status` não for `"active"`** | recusa `MISSING_RESOLUTION_PHASE` |

`category` e `creationPhase` podem ser omitidos: o apontamento é gravado com **"Não definida"** (id `0`) no que faltar, em qualquer projeto — é o item que nenhum projeto desativa ([§7](#catálogo-do-projeto--catálogo-global)). Enviar `null` dá no mesmo, inclusive na alteração: o campo nunca fica vazio.

🔴 **Sempre envie `locals` e `disciplines`, ambos não vazios.** Omitidos, nulos ou vazios, a criação é recusada sem gravar nada: `ISSUE_MUST_HAVE_LOCALS` para os locais, `ISSUE_MUST_HAVE_DISCIPLINES` para as disciplinas ([regra 3](#alteração-parcial-as-cinco-regras)). **Não repita a criação às cegas depois de uma falha de rede**: o apontamento pode ter sido gravado sem que a resposta chegasse, e a nova tentativa duplicaria o registro. Envie um `guid` próprio e reconcilie por ele antes de decidir qualquer nova tentativa.

⚠️ **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`, `category: Int`, `creationPhase: Int`, `deadline: Date`, `labels: [Int]`, `images: [IssueImageInput]`, `groupId: Int`, `guid: String`, `ccodeDocumentsReferences: [IssueCcodeDocumentReferenceInput]`, `documentsReferences: [IssueDocumentReferenceInput]`, `modelReferences: [ModelReferenceInput]`, `viewpoints: [IssueViewpointInput]`, `bimPins: JSON`.

Os valores de `status`, `priority` e `visibility` estão em [§9](#rótulos-que-a-api-não-traduz). Os identificadores de `locals`, `labels`, `disciplines` e `groupId` vêm do cadastro do projeto ([§7](#7-descoberta)); os de `category`, `creationPhase` e `resolutionPhase` vêm de um catálogo global, e **precisam estar `active` neste projeto** ([§7](#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 ([§9](#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.

   **Não use `remove: true` na alteração individual.** Para remover uma disciplina, omita-a e envie a lista final com as demais. `remove: true` é suportado somente pela mutation em lote ([lote](#escrita-em-lote)).

   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.**

   A situação também determina os valores efetivos: em `participate` e `follow`, o prazo fica nulo; `doneAt` fica nulo fora de `validate`, `validated` e `done`; nessas três situações, omitir `doneAt` registra a data atual.
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 |

#### `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.

⚠️ **Uma alteração em lote pode sobrescrever edições concorrentes**, inclusive em campos que não aparecem em `newValues`. Não use lote sobre registros que pessoas estejam editando sem antes comparar a versão lida com a versão atual ([concorrência](#evite-sobrescritas-concorrentes)).

🔴 **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 | a operação inteira pode ser recusada, sem gravar nem os apontamentos válidos |

É 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 ([§9](#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 |

⚠️ `ISSUE_NOT_FOUND` não confirma que o identificador esteja errado. Trate-o como resposta final, sem tentar inferir a existência ou a visibilidade do apontamento.

🔴 **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 — as duas restrições que surpreendem

⚠️ **O suporte de imagens em `createIssues` é limitado.** 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.
- Não use envio direto de arquivo (`originalFile`, `markedUpFile`, `markedUpThumbFile`) no lote.
- Não conte com geração de miniatura nem definição de capa pelo lote.

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`.

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.

#### 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 ([§6](#forma-e-custo-da-consulta)).

### Evite sobrescritas concorrentes

**Assuma que o apontamento pode mudar entre a leitura e a escrita.** Uma gravação concorrente pode sobrescrever a anterior sem produzir um conflito explícito. Use os marcadores de edição para impedir que a integração apague trabalho humano:

- `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 ([§8](#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 |
| Validade da URL | **1 hora** |
| Método aceito | **`PUT`** |
| Limite de taxa | O volume total da identidade (`user-envelope`), como qualquer outra chamada ([§6](#a-tabela-de-limites)) |

⚠️ **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 faz o armazenamento recusar a requisição. Envie o `Content-Type` devolvido pela mutation e nenhum outro cabeçalho além dos exigidos pelo cliente HTTP.

**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 limite de emissão é restrito: não pré-aloque para depois e não abra N chamadas paralelas para N arquivos.

---

## 13. 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.

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": "..." }`.

🔴 **Não use `visibility: "group"`.** Esse valor não faz parte do contrato da importação e faz todos os tópicos falharem. Use `creator`, `project_management` ou `public`.

> ⚠️ **`commentVisibility` não vem do arquivo BCF.** Se o arquivo puder conter comentários, envie o parâmetro explicitamente; sem uma visibilidade suportada, comentários podem falhar mesmo quando o apontamento é importado. Não use `group`.

🔴 **Não trate `commentVisibility` como controle isolado de confidencialidade.** A visibilidade efetiva também depende das permissões da credencial e da visibilidade do apontamento, e pode ficar mais ou menos restritiva do que o valor pedido. Use um usuário dedicado com acesso mínimo e, depois da importação, consulte os comentários e confira a visibilidade gravada.

### 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".

🔴 **Um `PUT` com sucesso não confirma a importação.** Fora da janela de 200 segundos, o upload pode ser aceito sem que o arquivo seja processado, e o usuário dono da credencial recebe o e-mail "⚠️ Importação BCF não concluída!" com o motivo "O arquivo não chegou a tempo para o processamento.". Se perder a janela, abra um novo processo e envie o arquivo pela nova URL.

### Passo 3 — o resultado

O resultado chega **por e-mail ao usuário dono da credencial**, num de dois e-mails: o de importação concluída — quantos apontamentos foram criados, quantos foram atualizados, e a lista de tópicos que falharam com o motivo de cada um — ou o de importação não concluída, com o motivo e o que fazer.

⚠️ **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 ([§8](#passo-1--apontamentos-alterados)).

⚠️ **Receber a URL confirma apenas a abertura do canal de upload.** Antes de começar, valide que o identificador do projeto é numérico, que o projeto está ativo e que a credencial tem permissão de visualizar e participar. A aceitação do arquivo e de cada tópico só é confirmada pelo e-mail de resultado; falhas podem afetar toda a importação ou apenas tópicos específicos. Confira também o efeito pela API antes de considerar a operação concluída.

### 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 considerado é o **`Guid` do arquivo de visualização** (`.bcfv`), não o do tópico. Para ter comportamento previsível, emita esse atributo e mantenha-o estável entre rodadas. Não dependa do comportamento de viewpoints sem identificador.

### 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 `A prioridade "High" não existe no Construflow (use Alta, Média ou Baixa). Ajuste o tópico e importe de novo.` 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 `O tópico não tem disciplina responsável. Atribua a ele uma disciplina do projeto e importe de novo.` 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 ([§12](#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` ([§7](#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 `O local "ND" informado no arquivo não existe neste projeto. Use um local do projeto e importe de novo.`

⚠️ **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` — no e-mail, `A categoria do tópico está desativada neste projeto. Ative-a no projeto, ou use outra categoria no tópico, e importe de novo.` / `A fase do tópico está desativada neste projeto. Ative-a no projeto, ou use outra fase no tópico, e importe de novo.`. `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 ([§7](#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` ([§7](#7-descoberta)) — é o que evita descobrir cinquenta falhas depois do fato.

### Armadilhas da importação

Além das acima, três falhas que exigem tratamento explícito:

- 🔴 **Subir o arquivo depois dos 200 segundos pode aceitar o `PUT` sem importar nada.** O e-mail que chega é o de importação não concluída, com o motivo "O arquivo não chegou a tempo para o processamento.". Não use o sucesso do upload como confirmação. 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 é o de importação não concluída, com o motivo "O arquivo não é um BCF válido, está corrompido ou está numa versão não aceita (aceitamos 2.1 e 3.0).", e nenhum apontamento é criado. Só **2.1** e **3.0** são aceitos.
- ⚠️ **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.

---

## 14. 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. [§3](#o-que-a-credencial-enxerga)
- [ ] Conectar pelo **OAuth**: a Chave de Acesso deixa de ser aceita em 15/10/2026, e quem ainda a usa migra antes dessa data. [§3](#autenticação-oauth)
- [ ] Executar a integração somente no servidor; nunca expor chave ou segredo em navegador, aplicativo móvel ou outro cliente distribuído. [§3](#o-que-a-credencial-enxerga)
- [ ] Em script conectado por código, renovar o refresh token **num processo só** e gravar o novo antes de usar o access token; com `invalid_grant`, reler o refresh token gravado antes de pedir um código novo. [§3](#validade-e-renovação)
- [ ] Enviar um **`User-Agent` identificável** em toda requisição. [§6](#dimensionamento)
- [ ] **Desligar o agrupamento automático de operações** do seu cliente GraphQL (`BatchHttpLink` e equivalentes). [§6](#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`. [§5](#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. [§5](#o-que-pode-ser-retentado)
- [ ] Tratar `429` **pelo status e pelos cabeçalhos**, nunca pela forma do corpo — recusas do limite total vêm **sem** a lista `errors`. [§6](#o-envelope-de-recusa-por-taxa)
- [ ] Colocar um **limitador global de ~1 requisição por segundo** e no máximo **2 simultâneas** (1 processo em sequência, no máximo 2). [§6](#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. [§7](#os-projetos-que-a-credencial-enxerga)
- [ ] Carregar o cadastro de cada projeto numa chamada só e guardá-lo do seu lado. [§7](#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. [§8](#carga-inicial)
- [ ] Gravar por **`upsert` idempotente** — bordas de janela inclusivas repetem o item da borda. [§8](#a-janela-de-data)
- [ ] Saber que a carga **não** traz comentário de apontamento excluído — esse dado não existe pela API. [§8](#carga-inicial)

### Em cada rodada

- [ ] Reenumerar os projetos e **comparar o conjunto de identificadores com o da rodada anterior** — trate um projeto que sumiu como mudança de acesso e reconcilie o estado local. [§7](#os-projetos-que-a-credencial-enxerga) · [§10](#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". [§8](#a-janela-de-data)
- [ ] Enviar as datas **com fuso explícito** (`Z` ou `-03:00`). [§8](#a-janela-de-data)
- [ ] Rodar **as duas consultas**, apontamentos e comentários. Comentário novo não move a data do apontamento. [§8](#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. [§11](#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. [§8](#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**. [§6](#forma-e-custo-da-consulta) · [§11](#paginação-e-cursor)
- [ ] Iterar até `hasNextPage` ser `false` — a última chamada vazia é esperada. [§11](#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. [§10](#o-que-você-detecta) · [§8](#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. [§9](#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. [§7](#frequência) · [§10](#o-que-você-não-detecta)
- [ ] Agendar a **recarga total por projeto** (mensal, por exemplo) e reconciliar por conjunto de identificadores. [§10](#a-recarga-total-periódica)
- [ ] Investigar todo projeto ativo cuja rodada voltou vazia — pode ser perda de acesso, que não produz erro. [§10](#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. [§12](#criar-apontamento) · [§7](#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. [§12](#criar-apontamento)
- [ ] Enviar **só os campos que mudaram** — campo omitido é preservado. [§12](#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. [§12](#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**. [§12](#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. [§12](#alteração-parcial-as-cinco-regras)
- [ ] Lembrar que o **prazo do apontamento é derivado das disciplinas** — enviar disciplinas sem prazo o apaga. [§12](#alteração-parcial-as-cinco-regras)
- [ ] No lote, **percorrer `rejectedIssues`**: a mutation devolve `200` sem `errors` mesmo com apontamentos recusados. [§12](#updateissues-é-uma-operação-parcial--leia-o-retorno)
- [ ] Ao editar comentário, **reenviar `mentionedUsers` sempre** — omitir apaga todas as menções. [§12](#criar-e-editar-comentário)
- [ ] Comparar `editedAt` antes de sobrescrever e tratar toda escrita como concorrente. [§12](#evite-sobrescritas-concorrentes)
- [ ] 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. [§12](#imagens-em-três-passos)
- [ ] Para criar apontamento **com imagem**, usar `createIssue` individual: a criação em lote não processa imagens. [§12](#criação-em-lote--as-duas-restrições-que-surpreendem)
- [ ] Manter o **de-para** de situação, prioridade e visibilidade do seu lado — a API não traduz. [§9](#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. [§13](#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. [§13](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Emitir os locais como **caminho completo de abreviações** (`^TORRE/PAV2`), lido do `abbreviationTree` do cadastro. [§13](#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. [§13](#como-os-campos-do-arquivo-são-interpretados)
- [ ] Não contar com `AssignedTo` para e-mails: entrada com `@` é descartada em silêncio. [§13](#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. [§13](#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 pode ter sucesso sem importar nada. [§13](#passo-2--subir-o-arquivo)
- [ ] Apontar a credencial para uma **caixa de e-mail monitorada**: o resultado só chega por lá. [§13](#passo-3--o-resultado)
- [ ] Conferir a situação dos tópicos depois de importar — `TopicStatus` desconhecido vira **ativo em silêncio**. [§13](#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. [§13](#comportamento-da-importação)
