Skip to content

Integrar um sistema de atendimento e despacho ​

Este guia é para quem mantém um — um sistema de atendimento e

— e precisa que ele converse com o :

declarar quem está de serviço e em qual , registrar e atualizar

, empenhar viatura e receber o que acontece depois, inclusive a.

Todo trecho de código aqui vem do pacote @geocontrol/exemplo-integrador-despacho, que roda contra o Geohub de verdade numa suíte de testes. Nada é colado: se o exemplo quebrar, o gate reprova antes de este guia envelhecer.

Visão geral: o Geohub é a fonte da verdade ​

A regra que explica todas as outras está no RDT-025: o Geohub é a fonte da verdade do que acontece em campo, e funciona sem o . O policial tem o aplicativo dele, a central tem as telas dela, e a ocorrência atravessa o ciclo inteiro sem que nenhum sistema externo participe.

O que a integração faz é duas coisas, e só duas:

  1. o CAD age no Geohub, em nome dele próprio. Todo comando leva a integrador, e ela vai em origem.autoria de cada evento que o comando causar. Quem olhar a ocorrência depois sabe que foi o CAD — não um policial, não "o sistema";
  2. o Geohub devolve o que aconteceu, em , pelo . O que nasceu no celular do policial chega ao CAD no mesmo formato do que nasceu no CAD.

Três consequências práticas disso, e é melhor conhecê-las antes de escrever a primeira linha:

  • o Geohub não espera o CAD. Se o callback estiver fora do ar, a ocorrência continua andando; o evento fica no outbox e é reenviado;
  • toda escrita passa pelos plugins. Gravar direto nas coleções do não gera retorno nenhum — e a permissão do integrador não opermite;
  • o CAD não dita estado. Ele pede atos (despachar, registrarChegada); o status é consequência, e quem decide se o ato cabe é o domínio.

SDK ou REST: como escolher ​

As duas formas de integrar saem do mesmo manifesto do plugin (RDT-027): as rotas em runtime, a spec OpenAPI e o cliente TypeScript. Elas não podem divergir, porque nenhuma delas é escrita à mão.

SDK TypeScriptREST + OpenAPI
para quemintegrador em Node/TypeScriptqualquer outra linguagem
o que você chamaIOcorrenciaHub, IJornadaHubPOST /plugins/ocorrencias, …
caminhos, status, cabeçalhoso SDK cuidavocê escreve (ou gera)
ligada por padrãovocê manda o cabeçalho

O exemplo deste guia declara portas no vocabulário do integrador e as implementa duas vezes — uma pelo SDK, outra por fetch. É por isso que o mesmo roteiro roda nos dois transportes sem um caso editado, e é um desenho que vale copiar: ele isola o seu código do transporte que você escolher.

ts
/** Os verbos de `jornada` que o papel `Integração - Sistema de despacho` usa. */
export interface PortaDeJornada {
  assumirComando(dados: {
    guarnicaoId: GuarnicaoId;
    jornadaId: JornadaId;
  }): Promise<Desfecho<void, FalhaDeJornada>>;
  constituirGuarnicao(
    dados: DadosDaGuarnicao,
  ): Promise<Desfecho<GuarnicaoId, FalhaDeJornada>>;
  declararProntidao(
    guarnicaoId: GuarnicaoId,
  ): Promise<Desfecho<void, FalhaDeJornada>>;
  /** Leitura: `undefined` quando a viatura não tem guarnição `disponivel`. */
  guarnicaoDisponivelNaViatura(
    viatura: ViaturaId,
  ): Promise<GuarnicaoElegivel | undefined>;
  iniciarJornada(dados: {
    id: JornadaId;
    posto: PostoOcupado;
  }): Promise<Desfecho<void, FalhaDeJornada>>;
  registrarApresentacao(
    usuario: UsuarioId,
  ): Promise<Desfecho<JornadaId, FalhaDeJornada>>;
}

/** Os verbos de `ocorrencia` que o mesmo papel usa. */
export interface PortaDeOcorrencia {
  atualizarOcorrencia(dados: {
    alteracoes: AlteracoesDeOcorrencia;
    id: OcorrenciaId;
  }): Promise<Desfecho<void, FalhaDeOcorrencia>>;
  despachar(dados: {
    instrucoes?: string;
    ocorrenciaId: OcorrenciaId;
    recurso: RecursoGuarnicao;
  }): Promise<Desfecho<DespachoId, FalhaDeOcorrencia>>;
  registrarOcorrencia(
    dados: DadosDeRegistroDeOcorrencia,
  ): Promise<Desfecho<OcorrenciaId, FalhaDeOcorrencia>>;
}
js
// #endregion desfecho
// #endregion portas
export {};

Prefira o SDK se puder. Ele é o próprio contrato do plugin do outro lado da rede — a suíte de contrato do IOcorrenciaHub roda contra ele sem um caso editado, o que torna "o cliente remoto cumpre o mesmo contrato" uma afirmação verificada e não um desenho.

ts
import {
  criarClienteJornada,
  matricula,
  modalidadeDeServicoId,
  usuarioId,
} from '@geocontrol/geohub-sdk/jornada';
import { CONEXAO } from './conexao.js';

const jornada = criarClienteJornada(CONEXAO);

// Na sessão de integrador, o usuário dos verbos é a matrícula do policial.
const policial = usuarioId(matricula('123456'));

// Dois verbos: comparecer à unidade e entrar em serviço no posto ocupado.
const apresentacao = await jornada.registrarApresentacao(policial);
if (!apresentacao.success) throw new Error(apresentacao.error.codigo);

export const jornadaId = apresentacao.data;

export const inicio = await jornada.iniciarJornada({
  id: jornadaId,
  posto: { modalidade: modalidadeDeServicoId('radio_patrulhamento') },
});
js
import {
  criarClienteJornada,
  matricula,
  modalidadeDeServicoId,
  usuarioId,
} from '@geocontrol/geohub-sdk/jornada';
import { CONEXAO } from './conexao.js';
const jornada = criarClienteJornada(CONEXAO);
// Na sessão de integrador, o usuário dos verbos é a matrícula do policial.
const policial = usuarioId(matricula('123456'));
// Dois verbos: comparecer à unidade e entrar em serviço no posto ocupado.
const apresentacao = await jornada.registrarApresentacao(policial);
if (!apresentacao.success) throw new Error(apresentacao.error.codigo);
export const jornadaId = apresentacao.data;
export const inicio = await jornada.iniciarJornada({
  id: jornadaId,
  posto: { modalidade: modalidadeDeServicoId('radio_patrulhamento') },
});

O SDK é o pacote @geocontrol/geohub-sdk, uma entrada por plugin (/jornada, /ocorrencia): recebe a raiz do Geohub e o token, e compõe o resto. A página pelo SDK tem os passos inteiros.

Onde está a OpenAPI, e como gerar cliente dela ​

A spec é publicada em /openapi.json do próprio site de documentação (e renderizada em API). Ela é OpenAPI 3.1, e as operações dos plugins trazem a tag Ocorrência — plugin exposto e <Termo id="t-113">Jornada</Termo> — <Termo id="plugin-exposto">plugin exposto</Termo>.

sh
# Java, Python, C#, Go… — o gerador da sua linguagem
openapi-generator-cli generate -i https://<seu-geohub-docs>/openapi.json \
  -g java -o ./cliente-geohub

Duas coisas para ler antes de gerar:

  • info.x-contratos-de-plugin traz a versão e o hash de cada contrato de plugin. A version do documento é do documento; a de um plugin é dele, e é por esse campo que você descobre que o contrato mudou sem comparar listas de paths;
  • servers tem uma URL de exemplo. Troque pela do seu ambiente.

Autenticação e o papel Integração - Sistema de despacho ​

O CAD autentica como usuário de serviço — não com o token de uma pessoa. É o que permite ao Geohub dizer, em cada evento, que quem agiu foi o sistema externo.

ts
/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(
  credenciais: CredenciaisDoIntegrador,
): Promise<SessaoDoIntegrador> {
  const buscar = credenciais.buscar ?? globalThis.fetch;
  const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
    body: JSON.stringify({
      email: credenciais.email,
      password: credenciais.senha,
    }),
    headers: { 'Content-Type': 'application/json' },
    method: 'POST',
  });
  if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
  const corpo = (await resposta.json()) as RespostaDeLogin;
  const token = corpo.data?.access_token;
  if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
  return new SessaoDoIntegrador(
    credenciais.geohub,
    token,
    credenciais.integradorId,
    credenciais.buscar,
    credenciais.esperar,
  );
}

/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes: TokenDoIntegrador): SessaoDoIntegrador {
  return new SessaoDoIntegrador(
    opcoes.geohub,
    opcoes.token,
    opcoes.integradorId,
    opcoes.buscar,
    opcoes.esperar,
  );
}

/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
readonly autoria: Autoria;

/** O `fetch` desta sessão. É ele que o SDK recebe. */
readonly buscar: typeof fetch;

/**
 * O token da sessão de serviço.
 *
 * Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
 * })`) — e porque esconder um segredo atrás de um método privado não o
 * torna menos segredo: o que protege é de onde ele veio.
 */
readonly token: string;
js
/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(credenciais) {
  const buscar = credenciais.buscar ?? globalThis.fetch;
  const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
    body: JSON.stringify({
      email: credenciais.email,
      password: credenciais.senha,
    }),
    headers: { 'Content-Type': 'application/json' },
    method: 'POST',
  });
  if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
  const corpo = await resposta.json();
  const token = corpo.data?.access_token;
  if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
  return new SessaoDoIntegrador(
    credenciais.geohub,
    token,
    credenciais.integradorId,
    credenciais.buscar,
    credenciais.esperar,
  );
}
/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes) {
  return new SessaoDoIntegrador(
    opcoes.geohub,
    opcoes.token,
    opcoes.integradorId,
    opcoes.buscar,
    opcoes.esperar,
  );
}
/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
autoria;
/** O `fetch` desta sessão. É ele que o SDK recebe. */
buscar;
/**
 * O token da sessão de serviço.
 *
 * Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
 * })`) — e porque esconder um segredo atrás de um método privado não o
 * torna menos segredo: o que protege é de onde ele veio.
 */
token;
sh
curl -X POST https://geohub.exemplo.gov.br/auth/login -H "Content-Type: application/json" \
  -d '{"email":"<usuário de serviço>","password":"…","mode":"json"}'
# {"data":{"access_token":"…","refresh_token":"…","expires":900000}}
# (ou o token estático do usuário de serviço, direto como Bearer)

O papel Integração - Sistema de despacho concede exatamente a superfície deste guia — e nada além:

o que o papel alcançaverbos
pôr policial em serviçoregistrarApresentacao, iniciarJornada, encerrarJornada
montar e preparar a constituirGuarnicao, substituirIntegrante, dissolverGuarnicao, assumirComando, declararProntidao, retirarDeProntidao
a ocorrênciaregistrarOcorrencia, atualizarOcorrencia, declararControlada, encerrarOcorrencia, cancelarOcorrencia
o despachodespachar, cancelarDespacho
leituraobterOcorrencia, listarOcorrenciasAbertas, listarDespachos, listarDespachosDaGuarnicao, obterDespacho, listarRegistro, e as leituras de jornada e

É a tabela ALCANCE_DO_INTEGRADOR_NA_OCORRENCIA e a ALCANCE_DO_INTEGRADOR_NA_JORNADA da exposição: um teste reprova se elas divergirem do porteiro, e esta página é escrita à mão a partir delas.

O que ele não alcança é tão importante quanto: pausa (iniciarPausa, encerrarPausa, iniciarPausaDaGuarnicao) e são do policial e do supervisor; liberarPolicial é do supervisor; e aceitarDespacho, recusarDespacho, iniciarDeslocamento, registrarChegada, registrarAcao, atualizarSituacao, solicitarApoio, encerrarDespacho e registrarOcorrenciaEmCampo são da equipe. O CAD não aceita despacho pela guarnição — quem está na viatura é quem diz que aceitou, que está a caminho e que chegou.

Uma chamada fora dessa lista volta 403 com código SEM_CAPACIDADE (o papel não alcança o verbo) ou GUARDA_DE_DOMINIO (o papel alcança, mas aquele objeto não é seu).

Idempotência, e os três ids que não se confundem ​

Comando de integrador exige chave de idempotência, um UUID no cabeçalho x-solicitacao-id. Sem ela a resposta é 400 SOLICITACAO_AUSENTE; fora do formato UUID, 400 SOLICITACAO_INVALIDA.

ts
/**
 * Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
 * chave** enquanto o comando não tem desfecho.
 *
 * Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
 * `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
 * o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
 * repeti-la seria martelar quem já respondeu.
 */
async comando<T>(
  executar: (solicitacaoId: string) => Promise<T>,
  tentativas = 3,
): Promise<T> {
  const solicitacaoId = uuidV7();
  let ultima: unknown;
  for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
    try {
      return await executar(solicitacaoId);
    } catch (erro) {
      const segundos = segundosSeEmAndamento(erro);
      if (segundos !== undefined) {
        await this.esperar(segundos * 1000);
      } else if (!(erro instanceof TypeError)) {
        throw erro;
      }
      ultima = erro;
    }
  }
  throw ultima;
}
js
/**
 * Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
 * chave** enquanto o comando não tem desfecho.
 *
 * Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
 * `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
 * o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
 * repeti-la seria martelar quem já respondeu.
 */
async comando(executar, tentativas = 3) {
  const solicitacaoId = uuidV7();
  let ultima;
  for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
    try {
      return await executar(solicitacaoId);
    } catch (erro) {
      const segundos = segundosSeEmAndamento(erro);
      if (segundos !== undefined) {
        await this.esperar(segundos * 1000);
      } else if (!(erro instanceof TypeError)) {
        throw erro;
      }
      ultima = erro;
    }
  }
  throw ultima;
}
sh
curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencias/$OCORRENCIA/despachos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f" \
  -H 'x-autoria: {"tipo":"integrador","id":"'$INTEGRADOR'"}' \
  -d '{"recurso":{"tipo":"guarnicao","guarnicaoId":"…","viatura":"RP-1207","composicao":[…],"identificador":"RP-1207"}}'
# {"dados":"<despachoId>"}                       (repetir a mesma chave devolve o mesmo, com "repetido":true)
# 409 {"erro":{"codigo":"SOLICITACAO_EM_ANDAMENTO"}}  Retry-After: 2

Repetir a mesma chave devolve o mesmo desfecho, com repetido: true, e não executa de novo. Isso é o que torna o retry seguro — e é por isso que a chave tem de ser cunhada antes da primeira tentativa, não dentro dela.

O retry pode encontrar a chave em três estados além do "já concluído":

respostaquandoo que fazer
409 SOLICITACAO_EM_ANDAMENTOo primeiro pedido com a mesma chave ainda executa (o seu timeout chegou antes da resposta)espere o Retry-After e repita com a mesma chave: aí vem o desfecho guardado
422 SOLICITACAO_REUSADAa chave já foi usada em outro comando — outro verbo, outro corpo ou outro integradoré defeito do seu lado: cunhe uma chave por comando. O desfecho do outro comando não é devolvido
executa de novo (repetido ausente)o primeiro explodiu (exceção do servidor, não falha de negócio), ou ficou em andamento mais de 5 min — o processo que o atendia caiunada: a chave volta a valer para o mesmo comando, e o Geohub guarda o rastro da tentativa que falhou

O 409 não é desfecho: nada executou de novo, e repetir a mesma chave depois do Retry-After é seguro. O comando do exemplo faz isso, e só isso — falha de rede e 409 SOLICITACAO_EM_ANDAMENTO repetem a chave; qualquer outra resposta é desfecho.

ts
/**
 * UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
 *
 * Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
 * **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
 * um índice que se lê em ordem de chegada.
 */
export function uuidV7(): string {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  const agora = Date.now();
  for (let i = 0; i < 6; i += 1) {
    bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
  }
  bytes[6] = ((bytes[6] as number) & 0x0f) | 0x70;
  bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80;
  const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
  return [
    hex.slice(0, 8),
    hex.slice(8, 12),
    hex.slice(12, 16),
    hex.slice(16, 20),
    hex.slice(20),
  ].join('-');
}
js
/**
 * UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
 *
 * Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
 * **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
 * um índice que se lê em ordem de chegada.
 */
export function uuidV7() {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  const agora = Date.now();
  for (let i = 0; i < 6; i += 1) {
    bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
  }
  bytes[6] = (bytes[6] & 0x0f) | 0x70;
  bytes[8] = (bytes[8] & 0x3f) | 0x80;
  const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
  return [
    hex.slice(0, 8),
    hex.slice(8, 12),
    hex.slice(12, 16),
    hex.slice(16, 20),
    hex.slice(20),
  ].join('-');
}
sh
# Um UUID v7 por comando, cunhado antes da primeira tentativa:
curl -X POST … -H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f"

Três ids circulam, e trocá-los é a confusão mais comum:

idquem cunhapara quê
solicitacaoIdvocê, um por comandoidempotência. Vai no cabeçalho x-solicitacao-id
origem.solicitacaoo Geohub, copiando o seucorrelacionar o evento com o comando que o causou
id do envelopeo Geohub, um por eventodeduplicação na entrega. Vai no corpo e no X-Geohub-Evento-Id

O id do envelope não é o solicitacaoId: um comando causa vários eventos, e cada um tem o seu. Deduplicar pelo solicitacaoId descartaria eventos legítimos.

O desfecho do comando é a resposta HTTP

Na REST gerada e no SDK, o comando é síncrono: ele responde 200 com o que o verbo devolveu (o ocorrenciaId, o despachoId) ou com o código da falha de negócio. Não espere um evento integracao:solicitacao-aceita para saber que deu certo — aquele evento pertence à API assíncrona anterior, que respondia 202.

Ids do integrador e tradução ​

O Geohub fala os ids dele: ocorrenciaId, despachoId, guarnicaoId e jornadaId são UUIDs cunhados por ele. A viatura é a exceção — ela viaja pelo prefixo operacional (RP-1207), que é o identificador que o conhece.

Para reconhecer a própria ocorrência num evento, mande referenciaExterna no registro: é o seu protocolo, guardado no Geohub e devolvido no agregado. É mais barato que manter uma tabela de-para do seu lado — e sobrevive a um restart do seu serviço. Para a sessão de integrador ela é obrigatória (sem ela, 403 GUARDA_DE_DOMINIO), e imutável: o PATCH não a aceita.

Os ids de pessoa (usuario) viajam como a matrícula do policial no seu sistema, e a exposição os traduz para o UsuarioId do Geohub pela coleção integrador_identidade (integrador, tipo: policial, id_externo, id_geohub), cadastrada pelo administrador do Geohub, uma linha por policial. Você só lê a tabela; sem a linha, o comando que cita o policial volta integrante-ausente. O cadastro dos policiais e das traduções é anterior à integração e não é assunto deste guia.

Pôr o policial em serviço e indicar a viatura ​

Sem guarnição em não há a quem despachar. O pool de despacho é formado por guarnições com status disponivel, e uma guarnição só chega lá depois de quatro atos.

A jornada é da pessoa; a viatura é da guarnição. Quem serve na central tem jornada igual, só não entra em guarnição.

ts
/**
 * Põe UM policial em serviço: apresentação (UC-06) e início (UC-14).
 *
 * São dois verbos, e não um: a apresentação é o comparecimento à unidade,
 * e entre ela e o início de serviço existe um intervalo real (RS-02) —
 * quem se apresentou ainda não trabalha. O CAD que só tem "entrou de
 * serviço" chama os dois em seguida, como aqui; o que distingue os dois
 * momentos chama cada um no seu.
 */
async porEmServico(
  matricula: Matricula,
  posto: PostoOcupado,
): Promise<JornadaId> {
  const apresentada = await this.jornada.registrarApresentacao(
    usuarioDaMatricula(matricula),
  );
  const jornadaId = this.exigir(apresentada, 'registrarApresentacao');
  this.exigir(
    await this.jornada.iniciarJornada({ id: jornadaId, posto }),
    'iniciarJornada',
  );
  return jornadaId;
}
js
/**
 * Põe UM policial em serviço: apresentação (UC-06) e início (UC-14).
 *
 * São dois verbos, e não um: a apresentação é o comparecimento à unidade,
 * e entre ela e o início de serviço existe um intervalo real (RS-02) —
 * quem se apresentou ainda não trabalha. O CAD que só tem "entrou de
 * serviço" chama os dois em seguida, como aqui; o que distingue os dois
 * momentos chama cada um no seu.
 */
async porEmServico(matricula, posto) {
  const apresentada = await this.jornada.registrarApresentacao(
    usuarioDaMatricula(matricula),
  );
  const jornadaId = this.exigir(apresentada, 'registrarApresentacao');
  this.exigir(
    await this.jornada.iniciarJornada({ id: jornadaId, posto }),
    'iniciarJornada',
  );
  return jornadaId;
}

A apresentação e o início são dois verbos de propósito: entre comparecer à unidade e entrar em serviço existe um intervalo real. Um CAD que só registre "entrou de serviço" chama os dois em seguida, como acima.

ts
/**
 * Monta a guarnição na viatura e a põe no pool de despacho.
 *
 * A ordem não é negociável e o Geohub a cobra: `declararProntidao` antes
 * de `assumirComando` devolve `comando-nao-assumido`, e `assumirComando`
 * com a jornada do comandante ainda `apresentada` devolve
 * `jornada-nao-iniciada`. As duas são falhas de **negócio** — vêm como
 * resultado, com código, e dizem exatamente o que falta.
 */
async constituirGuarnicao(
  viatura: ViaturaId,
  equipe: readonly PolicialEmServico[],
): Promise<GuarnicaoId> {
  const comandante = equipe.find((p) => p.posto === 'comandante');
  if (comandante === undefined) {
    throw new FalhaDaEscala(
      SEM_POSTO_DE_COMANDANTE,
      'constituirGuarnicao',
      'a composição precisa de um comandante',
    );
  }
  const guarnicaoId = this.exigir(
    await this.jornada.constituirGuarnicao({
      composicao: equipe.map((p) => ({
        posto: p.posto,
        usuario: usuarioDaMatricula(p.matricula),
      })),
      viatura,
    }),
    'constituirGuarnicao',
  );
  this.exigir(
    await this.jornada.assumirComando({
      guarnicaoId,
      jornadaId: comandante.jornadaId,
    }),
    'assumirComando',
  );
  this.exigir(
    await this.jornada.declararProntidao(guarnicaoId),
    'declararProntidao',
  );
  return guarnicaoId;
}
js
/**
 * Monta a guarnição na viatura e a põe no pool de despacho.
 *
 * A ordem não é negociável e o Geohub a cobra: `declararProntidao` antes
 * de `assumirComando` devolve `comando-nao-assumido`, e `assumirComando`
 * com a jornada do comandante ainda `apresentada` devolve
 * `jornada-nao-iniciada`. As duas são falhas de **negócio** — vêm como
 * resultado, com código, e dizem exatamente o que falta.
 */
async constituirGuarnicao(viatura, equipe) {
  const comandante = equipe.find((p) => p.posto === 'comandante');
  if (comandante === undefined) {
    throw new FalhaDaEscala(
      SEM_POSTO_DE_COMANDANTE,
      'constituirGuarnicao',
      'a composição precisa de um comandante',
    );
  }
  const guarnicaoId = this.exigir(
    await this.jornada.constituirGuarnicao({
      composicao: equipe.map((p) => ({
        posto: p.posto,
        usuario: usuarioDaMatricula(p.matricula),
      })),
      viatura,
    }),
    'constituirGuarnicao',
  );
  this.exigir(
    await this.jornada.assumirComando({
      guarnicaoId,
      jornadaId: comandante.jornadaId,
    }),
    'assumirComando',
  );
  this.exigir(
    await this.jornada.declararProntidao(guarnicaoId),
    'declararProntidao',
  );
  return guarnicaoId;
}

A ordem é cobrada pelo domínio, e as recusas dizem exatamente o que falta: declararProntidao antes de assumirComando devolve comando-nao-assumido; assumirComando com a jornada do ainda apresentada devolve jornada-nao-iniciada.

rotaverbo
POST /plugins/policiais/{usuario}/apresentacaoregistrarApresentacao
POST /plugins/jornadas/{id}/inicioiniciarJornada
POST /plugins/guarnicoesconstituirGuarnicao
POST /plugins/guarnicoes/{guarnicaoId}/comandoassumirComando
POST /plugins/guarnicoes/{guarnicaoId}/prontidaodeclararProntidao
GET /plugins/viaturas/{viatura}/guarnicao-disponivelguarnicaoDisponivelNaViatura

Os dois transportes, lado a lado ​

Pelo SDK, não há caminho nenhum escrito — o cliente é o IJornadaHub:

ts
/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao: SessaoDoIntegrador): PortaDeJornada {
  const cliente = criarClienteJornada({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });

  /** Os comandos presos à chave de idempotência do comando: o retry a repete. */
  const comando = (solicitacaoId: string) =>
    cliente.comSolicitacao(solicitacaoId);

  return {
    assumirComando: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).assumirComando(dados)),
      ),
    constituirGuarnicao: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).constituirGuarnicao(dados)),
      ),
    declararProntidao: (guarnicaoId) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
      ),
    guarnicaoDisponivelNaViatura: (viatura) =>
      cliente.guarnicaoDisponivelNaViatura(viatura),
    iniciarJornada: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).iniciarJornada(dados)),
      ),
    registrarApresentacao: (usuario) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarApresentacao(usuario)),
      ),
  };
}
js
/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao) {
  const cliente = criarClienteJornada({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });
  /** Os comandos presos à chave de idempotência do comando: o retry a repete. */
  const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
  return {
    assumirComando: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).assumirComando(dados)),
      ),
    constituirGuarnicao: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).constituirGuarnicao(dados)),
      ),
    declararProntidao: (guarnicaoId) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
      ),
    guarnicaoDisponivelNaViatura: (viatura) =>
      cliente.guarnicaoDisponivelNaViatura(viatura),
    iniciarJornada: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).iniciarJornada(dados)),
      ),
    registrarApresentacao: (usuario) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarApresentacao(usuario)),
      ),
  };
}

Pela REST, os caminhos são os da spec, e os três cabeçalhos são o protocolo inteiro:

ts
/** O plugin `jornada` pelos caminhos da OpenAPI. */
export function jornadaPorRest(sessao: SessaoDoIntegrador): PortaDeJornada {
  return {
    assumirComando: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/guarnicoes/${dados.guarnicaoId}/comando`,
            { corpo: { jornadaId: dados.jornadaId }, solicitacaoId },
          ),
          nada,
          falhaDeJornada,
        ),
      ),

    constituirGuarnicao: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe<GuarnicaoId, FalhaDeJornada>(
          await sessao.requisitar('POST', '/guarnicoes', {
            corpo: dados,
            solicitacaoId,
          }),
          (id) => guarnicaoId(texto(id)),
          falhaDeJornada,
        ),
      ),

    declararProntidao: (id) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', url`/guarnicoes/${id}/prontidao`, {
            solicitacaoId,
          }),
          nada,
          falhaDeJornada,
        ),
      ),

    // Leitura não leva `solicitacaoId`: consultar não é agir, e repetir um
    // `GET` não muda nada.
    guarnicaoDisponivelNaViatura: async (viatura) => {
      const resposta = await sessao.requisitar(
        'GET',
        url`/viaturas/${viatura}/guarnicao-disponivel`,
      );
      const desfecho = await desfechoDe(
        resposta,
        guarnicaoElegivel,
        falhaDeJornada,
      );
      return desfecho.aceito ? desfecho.valor : undefined;
    },

    iniciarJornada: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', url`/jornadas/${dados.id}/inicio`, {
            corpo: { posto: dados.posto },
            solicitacaoId,
          }),
          nada,
          falhaDeJornada,
        ),
      ),

    registrarApresentacao: (usuario) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe<JornadaId, FalhaDeJornada>(
          await sessao.requisitar(
            'POST',
            url`/policiais/${usuario}/apresentacao`,
            { solicitacaoId },
          ),
          (id) => jornadaId(texto(id)),
          falhaDeJornada,
        ),
      ),
  };
}
js
/** O plugin `jornada` pelos caminhos da OpenAPI. */
export function jornadaPorRest(sessao) {
  return {
    assumirComando: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/guarnicoes/${dados.guarnicaoId}/comando`,
            { corpo: { jornadaId: dados.jornadaId }, solicitacaoId },
          ),
          nada,
          falhaDeJornada,
        ),
      ),
    constituirGuarnicao: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', '/guarnicoes', {
            corpo: dados,
            solicitacaoId,
          }),
          (id) => guarnicaoId(texto(id)),
          falhaDeJornada,
        ),
      ),
    declararProntidao: (id) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', url`/guarnicoes/${id}/prontidao`, {
            solicitacaoId,
          }),
          nada,
          falhaDeJornada,
        ),
      ),
    // Leitura não leva `solicitacaoId`: consultar não é agir, e repetir um
    // `GET` não muda nada.
    guarnicaoDisponivelNaViatura: async (viatura) => {
      const resposta = await sessao.requisitar(
        'GET',
        url`/viaturas/${viatura}/guarnicao-disponivel`,
      );
      const desfecho = await desfechoDe(
        resposta,
        guarnicaoElegivel,
        falhaDeJornada,
      );
      return desfecho.aceito ? desfecho.valor : undefined;
    },
    iniciarJornada: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', url`/jornadas/${dados.id}/inicio`, {
            corpo: { posto: dados.posto },
            solicitacaoId,
          }),
          nada,
          falhaDeJornada,
        ),
      ),
    registrarApresentacao: (usuario) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/policiais/${usuario}/apresentacao`,
            { solicitacaoId },
          ),
          (id) => jornadaId(texto(id)),
          falhaDeJornada,
        ),
      ),
  };
}
ts
/**
 * O `fetch` autenticado da REST gerada — o que o integrador em outra
 * linguagem escreveria à mão a partir da OpenAPI.
 *
 * Os três cabeçalhos são o protocolo inteiro: o token diz quem entrou, o
 * `x-autoria` diz em nome de quem se age (e vai para ser **conferido**,
 * nunca para mandar) e o `x-solicitacao-id` é a chave de idempotência. O
 * corpo é o argumento do verbo, e nada mais.
 */
async requisitar(
  metodo: 'GET' | 'PATCH' | 'POST',
  caminho: string,
  opcoes: { corpo?: unknown; solicitacaoId?: string } = {},
): Promise<Response> {
  const temCorpo = opcoes.corpo !== undefined;
  return this.buscar(`${this.plugins}${caminho}`, {
    ...(temCorpo ? { body: JSON.stringify(opcoes.corpo) } : {}),
    headers: {
      Authorization: `Bearer ${this.token}`,
      ...(temCorpo ? { 'Content-Type': 'application/json' } : {}),
      ...(metodo === 'GET'
        ? {}
        : { 'x-autoria': JSON.stringify(this.autoria) }),
      ...(opcoes.solicitacaoId === undefined
        ? {}
        : { 'x-solicitacao-id': opcoes.solicitacaoId }),
    },
    method: metodo,
  });
}
js
/**
 * O `fetch` autenticado da REST gerada — o que o integrador em outra
 * linguagem escreveria à mão a partir da OpenAPI.
 *
 * Os três cabeçalhos são o protocolo inteiro: o token diz quem entrou, o
 * `x-autoria` diz em nome de quem se age (e vai para ser **conferido**,
 * nunca para mandar) e o `x-solicitacao-id` é a chave de idempotência. O
 * corpo é o argumento do verbo, e nada mais.
 */
async requisitar(metodo, caminho, opcoes = {}) {
  const temCorpo = opcoes.corpo !== undefined;
  return this.buscar(`${this.plugins}${caminho}`, {
    ...(temCorpo ? { body: JSON.stringify(opcoes.corpo) } : {}),
    headers: {
      Authorization: `Bearer ${this.token}`,
      ...(temCorpo ? { 'Content-Type': 'application/json' } : {}),
      ...(metodo === 'GET'
        ? {}
        : { 'x-autoria': JSON.stringify(this.autoria) }),
      ...(opcoes.solicitacaoId === undefined
        ? {}
        : { 'x-solicitacao-id': opcoes.solicitacaoId }),
    },
    method: metodo,
  });
}

Registrar e atualizar a ocorrência ​

ts
/**
 * Registra a ocorrência e devolve o id que o Geohub cunhou.
 *
 * `referenciaExterna` é o protocolo do CAD, e é obrigatória. Mandá-la é o
 * que permite, depois, reconhecer a própria ocorrência num evento — e é
 * mais barato que manter uma tabela de-para do lado de cá.
 *
 * Ela também identifica uma só ocorrência por integrador. O CAD que não
 * recebeu a resposta e reenvia com `solicitacaoId` novo recebe `409
 * referencia-externa-ja-registrada`, com o id da que já existe em
 * `contexto.ocorrenciaId`: é com ela que ele segue, sem abrir outra.
 */
async registrar(dados: DadosDeRegistroDeOcorrencia): Promise<OcorrenciaId> {
  const desfecho = await this.ocorrencia.registrarOcorrencia(dados);
  if (
    !desfecho.aceito &&
    desfecho.codigo ===
      CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
  ) {
    return desfecho.contexto.ocorrenciaId;
  }
  return this.exigir(desfecho, 'registrarOcorrencia');
}
js
/**
 * Registra a ocorrência e devolve o id que o Geohub cunhou.
 *
 * `referenciaExterna` é o protocolo do CAD, e é obrigatória. Mandá-la é o
 * que permite, depois, reconhecer a própria ocorrência num evento — e é
 * mais barato que manter uma tabela de-para do lado de cá.
 *
 * Ela também identifica uma só ocorrência por integrador. O CAD que não
 * recebeu a resposta e reenvia com `solicitacaoId` novo recebe `409
 * referencia-externa-ja-registrada`, com o id da que já existe em
 * `contexto.ocorrenciaId`: é com ela que ele segue, sem abrir outra.
 */
async registrar(dados) {
  const desfecho = await this.ocorrencia.registrarOcorrencia(dados);
  if (
    !desfecho.aceito &&
    desfecho.codigo ===
      CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
  ) {
    return desfecho.contexto.ocorrenciaId;
  }
  return this.exigir(desfecho, 'registrarOcorrencia');
}
ts
/**
 * Complementa os dados de negócio da ocorrência.
 *
 * Só os de negócio: o status não entra aqui. Ele muda por verbo próprio —
 * e, no caso de `em_atendimento`, por consequência da primeira chegada ao
 * local. Um `PATCH` que aceitasse `status` convidaria o CAD a escolher o
 * próximo estado, que não é escolha dele.
 */
async atualizar(
  id: OcorrenciaId,
  alteracoes: AlteracoesDeOcorrencia,
): Promise<void> {
  this.exigir(
    await this.ocorrencia.atualizarOcorrencia({ alteracoes, id }),
    'atualizarOcorrencia',
  );
}
js
/**
 * Complementa os dados de negócio da ocorrência.
 *
 * Só os de negócio: o status não entra aqui. Ele muda por verbo próprio —
 * e, no caso de `em_atendimento`, por consequência da primeira chegada ao
 * local. Um `PATCH` que aceitasse `status` convidaria o CAD a escolher o
 * próximo estado, que não é escolha dele.
 */
async atualizar(id, alteracoes) {
  this.exigir(
    await this.ocorrencia.atualizarOcorrencia({ alteracoes, id }),
    'atualizarOcorrencia',
  );
}
sh
curl -X PATCH https://geohub.exemplo.gov.br/plugins/ocorrencias/$OCORRENCIA -H "Authorization: Bearer $TOKEN" -H "x-solicitacao-id: $(uuidv7)" -H "Content-Type: application/json" \
  -d '{"alteracoes":{"descricao":"dois suspeitos"}}'
# {"dados":null}

O PATCH complementa só dados de negócio. O status não entra: ele muda por verbo próprio, e em_atendimento muda por consequência da primeira chegada ao local. A referenciaExterna também não: ela é a identidade da ocorrência no seu sistema. Campo desconhecido no corpo é 400, não ruído tolerado.

Uma referência, uma ocorrência ​

A referenciaExterna identifica uma só ocorrência por integrador. A idempotência por solicitacaoId cobre o retry com a mesma chave; a referência cobre o caso que ela não alcança — o CAD que não recebeu a resposta e reenvia com chave nova. Esse reenvio não abre uma segunda ocorrência: a resposta é 409 referencia-externa-ja-registrada, com o id da que já existe em erro.contexto.ocorrenciaId, e é com ela que você segue.

json
{
  "erro": {
    "codigo": "referencia-externa-ja-registrada",
    "contexto": { "ocorrenciaId": "0199…" },
    "mensagem": "esta referência externa já identifica uma ocorrência"
  }
}

A unicidade é por integrador: outro sistema pode usar o mesmo número de protocolo sem colidir com o seu. Quem registra pelo próprio Geohub (a central, o policial em campo) é um originador só.

rotaverbo
POST /plugins/ocorrenciasregistrarOcorrencia
PATCH /plugins/ocorrencias/{id}atualizarOcorrencia
GET /plugins/ocorrencias/{id}obterOcorrencia
GET /plugins/ocorrencias/{ocorrenciaId}/registrolistarRegistro

Pedir despacho ​

Duas coisas valem o guia inteiro:

O despacho é por RECURSO, não por ocorrência

Cada viatura empenhada é um despacho, com despachoId e status próprios. Uma ocorrência pode ter vários — e o que acontece com um não diz nada sobre os outros.

A viatura não é o recurso; a guarnição dela é

O despachar recebe um já resolvido. Quem traduz "viatura RP-1207" em "a guarnição que está nela agora" é o plugin jornada. Por isso "viatura sem guarnição disponível" é um resultado, não um 404.

ts
/**
 * Empenha a guarnição de uma viatura, e devolve o `DespachoId`.
 *
 * O desfecho é **valor**: `viatura-sem-guarnicao-disponivel` é a resposta
 * normal para uma viatura que não tem equipe pronta agora, e o despachante
 * a trata escolhendo outra viatura. Lançar exceção aqui faria o caso mais
 * comum do dia parecer um incidente.
 */
async despacharViatura(
  ocorrencia: OcorrenciaId,
  viatura: ViaturaId,
  instrucoes?: string,
): Promise<Desfecho<DespachoId, FalhaDaViatura | FalhaDeOcorrencia>> {
  const guarnicao = await this.escala.guarnicaoDisponivelNaViatura(viatura);
  if (guarnicao === undefined) {
    return {
      aceito: false,
      codigo: VIATURA_SEM_GUARNICAO_DISPONIVEL,
      detalhe: `a viatura ${viatura} não tem guarnição disponível`,
    };
  }
  return this.ocorrencia.despachar({
    ...(instrucoes === undefined ? {} : { instrucoes }),
    ocorrenciaId: ocorrencia,
    recurso: recursoDaGuarnicao(guarnicao),
  });
}
js
/**
 * Empenha a guarnição de uma viatura, e devolve o `DespachoId`.
 *
 * O desfecho é **valor**: `viatura-sem-guarnicao-disponivel` é a resposta
 * normal para uma viatura que não tem equipe pronta agora, e o despachante
 * a trata escolhendo outra viatura. Lançar exceção aqui faria o caso mais
 * comum do dia parecer um incidente.
 */
async despacharViatura(ocorrencia, viatura, instrucoes) {
  const guarnicao = await this.escala.guarnicaoDisponivelNaViatura(viatura);
  if (guarnicao === undefined) {
    return {
      aceito: false,
      codigo: VIATURA_SEM_GUARNICAO_DISPONIVEL,
      detalhe: `a viatura ${viatura} não tem guarnição disponível`,
    };
  }
  return this.ocorrencia.despachar({
    ...(instrucoes === undefined ? {} : { instrucoes }),
    ocorrenciaId: ocorrencia,
    recurso: recursoDaGuarnicao(guarnicao),
  });
}
rotaverbo
POST /plugins/ocorrencias/{ocorrenciaId}/despachosdespachar
GET /plugins/ocorrencias/{ocorrenciaId}/despachoslistarDespachos
GET /plugins/despachos/{id}obterDespacho
POST /plugins/despachos/{id}/cancelamentocancelarDespacho

E os dois transportes da ocorrência, lado a lado:

ts
/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(
  sessao: SessaoDoIntegrador,
): PortaDeOcorrencia {
  const cliente = criarClienteOcorrencia({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });

  const comando = (solicitacaoId: string) =>
    cliente.comSolicitacao(solicitacaoId);

  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
      ),
    despachar: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).despachar(dados)),
      ),
    registrarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarOcorrencia(dados)),
      ),
  };
}
js
/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(sessao) {
  const cliente = criarClienteOcorrencia({
    autoria: sessao.autoria,
    buscar: sessao.buscar,
    geohub: sessao.geohub,
    token: sessao.token,
  });
  const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
      ),
    despachar: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).despachar(dados)),
      ),
    registrarOcorrencia: (dados) =>
      sessao.comando(async (id) =>
        comoDesfecho(await comando(id).registrarOcorrencia(dados)),
      ),
  };
}
ts
/** O plugin `ocorrencia` pelos caminhos da OpenAPI. */
export function ocorrenciaPorRest(
  sessao: SessaoDoIntegrador,
): PortaDeOcorrencia {
  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('PATCH', url`/ocorrencias/${dados.id}`, {
            corpo: { alteracoes: dados.alteracoes },
            solicitacaoId,
          }),
          nada,
          falhaDeOcorrencia,
        ),
      ),

    despachar: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe<DespachoId, FalhaDeOcorrencia>(
          await sessao.requisitar(
            'POST',
            url`/ocorrencias/${dados.ocorrenciaId}/despachos`,
            {
              corpo: {
                ...(dados.instrucoes === undefined
                  ? {}
                  : { instrucoes: dados.instrucoes }),
                recurso: dados.recurso,
              },
              solicitacaoId,
            },
          ),
          (id) => despachoId(texto(id)),
          falhaDeOcorrencia,
        ),
      ),

    registrarOcorrencia: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe<OcorrenciaId, FalhaDeOcorrencia>(
          await sessao.requisitar('POST', '/ocorrencias', {
            corpo: dados,
            solicitacaoId,
          }),
          (id) => ocorrenciaId(texto(id)),
          falhaDeOcorrencia,
        ),
      ),
  };
}
js
/** O plugin `ocorrencia` pelos caminhos da OpenAPI. */
export function ocorrenciaPorRest(sessao) {
  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('PATCH', url`/ocorrencias/${dados.id}`, {
            corpo: { alteracoes: dados.alteracoes },
            solicitacaoId,
          }),
          nada,
          falhaDeOcorrencia,
        ),
      ),
    despachar: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/ocorrencias/${dados.ocorrenciaId}/despachos`,
            {
              corpo: {
                ...(dados.instrucoes === undefined
                  ? {}
                  : { instrucoes: dados.instrucoes }),
                recurso: dados.recurso,
              },
              solicitacaoId,
            },
          ),
          (id) => despachoId(texto(id)),
          falhaDeOcorrencia,
        ),
      ),
    registrarOcorrencia: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', '/ocorrencias', {
            corpo: dados,
            solicitacaoId,
          }),
          (id) => ocorrenciaId(texto(id)),
          falhaDeOcorrencia,
        ),
      ),
  };
}

Receber os eventos ​

O Geohub entrega no seu callback, por POST, o envelope do plugin — o mesmo objeto que o GET de eventos devolve a uma sessão:

json
{
  "id": "0199bb3e-0000-7000-8000-00000000004a",
  "ocorridoEm": "2026-10-31T12:04:11.000Z",
  "origem": {
    "autoria": { "id": "…", "tipo": "usuario" },
    "plugin": "ocorrencia",
    "solicitacao": "…",
    "versao": "1.0.0"
  },
  "payload": {
    "de": "em_deslocamento",
    "id": "…",
    "ocorrenciaId": "…",
    "para": "no_local"
  },
  "tipo": "ocorrencia:despacho-mudou-status"
}

O metadado de entrega vai em cabeçalho, nunca no corpo — pô-lo no corpo daria duas formas ao mesmo evento, já que o corpo é o mesmo envelope que o polling entrega:

ts
const CABECALHO = {
  AGREGADO: 'x-geohub-agregado',
  ASSINATURA: 'x-geohub-assinatura',
  EVENTO_ID: 'x-geohub-evento-id',
  SEQUENCIA: 'x-geohub-sequencia',
  TIMESTAMP: 'x-geohub-timestamp',
} as const;

/** O plugin do DESFECHO do comando — a resposta, não o eco. */
const PLUGIN_DE_INTEGRACAO = 'integracao';

/** ±5 min, dos dois lados: relógio adiantado é tão comum quanto atrasado. */
const TOLERANCIA_EM_SEGUNDOS = 300;
js
const CABECALHO = {
  AGREGADO: 'x-geohub-agregado',
  ASSINATURA: 'x-geohub-assinatura',
  EVENTO_ID: 'x-geohub-evento-id',
  SEQUENCIA: 'x-geohub-sequencia',
  TIMESTAMP: 'x-geohub-timestamp',
};
/** O plugin do DESFECHO do comando — a resposta, não o eco. */
const PLUGIN_DE_INTEGRACAO = 'integracao';
/** ±5 min, dos dois lados: relógio adiantado é tão comum quanto atrasado. */
const TOLERANCIA_EM_SEGUNDOS = 300;
sh
# O que o Geohub manda ao seu callback_url:
POST https://cad.exemplo.gov.br/geohub/eventos
Content-Type: application/json
X-Geohub-Evento-Id: 0199bb3e-0000-7000-8000-00000000004a
X-Geohub-Timestamp: 1761912251
X-Geohub-Assinatura: sha256=<hex do HMAC-SHA256(segredo, "1761912251." + corpo cru)>
X-Geohub-Agregado: ocorrencia:0199…
X-Geohub-Sequencia: 4

{ …o envelope… }
# responda 204

A tabela de eventos ​

tipoquando acontecepayload
ocorrencia:registradaa ocorrência nasceu{ ocorrencia } — o agregado inteiro
ocorrencia:atualizadadados de negócio mudaram{ id, alteracoes, versao }
ocorrencia:mudou-statusa OCORRÊNCIA mudou de status{ id, de, para, motivo?, versao }
ocorrencia:despachadaum recurso foi empenhado{ despacho } — o agregado do despacho
ocorrencia:despacho-mudou-statusUM despacho mudou: aceito, em , no local, encerrado{ id, ocorrenciaId, de, para, motivo?, resultado? }
ocorrencia:registro-adicionadoação, situação ou pedido de apoio na narrativa{ ocorrenciaId, entrada }
jornada:registradapolicial se apresentou{ jornada }
jornada:mudou-statusjornada entrou em serviço, pausou, encerrou{ id, de, para, posto?, versao }
jornada:guarnicao-constituidaguarnição montada numa viatura{ guarnicao }
jornada:comando-assumidocomandante assumiu{ id, comandante, versao }
jornada:guarnicao-mudou-statusa guarnição entrou no pool, foi designada, chegou{ id, de, para, despachoId?, versao }
jornada:pausa-registrada / jornada:pausa-mudou-statuspausa de pessoa ou de equipe{ pausa } / { id, de, para, motivo? }

A chegada ao local não é um evento próprio ​

Não existe um viatura-chegou. Chegada ao local é a mudança de status de um despacho para no_local (ocorrencia:despacho-mudou-status), e da guarnição dele (jornada:guarnicao-mudou-status).

E a consequência que custa caro quando se erra:

Uma viatura no local não é a ocorrência no local

A primeira chegada leva a ocorrência a em_atendimento (ocorrencia:mudou-status). As demais viaturas podem continuar em_deslocamento — e continuam. Guarde o estado por despachoId, e nunca infira o estado dos outros recursos a partir de um deles. O status da ocorrência e o de cada despacho são coisas diferentes.

ts
/**
 * O estado de UM despacho.
 *
 * Nunca o estado "da ocorrência no local": `no_local` é propriedade do
 * despacho de uma viatura, e a ocorrência pode ter outra ainda em
 * deslocamento.
 */
despacho(id: DespachoId): EstadoDoDespacho | undefined {
  return this.despachos.get(id);
}
js
/**
 * O estado de UM despacho.
 *
 * Nunca o estado "da ocorrência no local": `no_local` é propriedade do
 * despacho de uma viatura, e a ocorrência pode ter outra ainda em
 * deslocamento.
 */
despacho(id) {
  return this.despachos.get(id);
}
ts
/**
 * A projeção: o que o CAD mostra na tela.
 *
 * Três tipos bastam para o atendimento, e os três dizem coisas
 * diferentes:
 *
 * - `ocorrencia:despachada` — nasceu um despacho. É aqui que o
 *   `despachoId` aparece pela primeira vez;
 * - `ocorrencia:despacho-mudou-status` — a chegada ao local é esta, com
 *   `para: 'no_local'`. Não existe um `viatura-chegou`;
 * - `ocorrencia:mudou-status` — a OCORRÊNCIA mudou. `em_atendimento` é
 *   consequência da PRIMEIRA chegada, e não diz nada sobre os outros
 *   despachos.
 */
private aplicar(envelope: EnvelopeDoGeohub): void {
  this.aplicados.push(envelope);
  const payload = envelope.payload;
  if (envelope.tipo === 'ocorrencia:despachada') {
    const despacho = payload['despacho'] as
      | {
          id: string;
          ocorrenciaId: string;
          recurso?: { identificador?: string };
          status: string;
        }
      | undefined;
    if (despacho === undefined) return;
    const id = despachoId(despacho.id);
    this.despachos.set(id, {
      despachoId: id,
      ocorrenciaId: ocorrenciaId(despacho.ocorrenciaId),
      ...(despacho.recurso?.identificador === undefined
        ? {}
        : { recurso: despacho.recurso.identificador }),
      status: despacho.status,
    });
    return;
  }
  if (envelope.tipo === 'ocorrencia:despacho-mudou-status') {
    const id = despachoId(String(payload['id']));
    const anterior = this.despachos.get(id);
    this.despachos.set(id, {
      despachoId: id,
      ocorrenciaId: ocorrenciaId(String(payload['ocorrenciaId'])),
      ...(anterior?.recurso === undefined
        ? {}
        : { recurso: anterior.recurso }),
      status: String(payload['para']),
    });
    return;
  }
  if (envelope.tipo === 'ocorrencia:mudou-status') {
    this.ocorrencias.set(
      ocorrenciaId(String(payload['id'])),
      String(payload['para']),
    );
  }
}
js
/**
 * A projeção: o que o CAD mostra na tela.
 *
 * Três tipos bastam para o atendimento, e os três dizem coisas
 * diferentes:
 *
 * - `ocorrencia:despachada` — nasceu um despacho. É aqui que o
 *   `despachoId` aparece pela primeira vez;
 * - `ocorrencia:despacho-mudou-status` — a chegada ao local é esta, com
 *   `para: 'no_local'`. Não existe um `viatura-chegou`;
 * - `ocorrencia:mudou-status` — a OCORRÊNCIA mudou. `em_atendimento` é
 *   consequência da PRIMEIRA chegada, e não diz nada sobre os outros
 *   despachos.
 */
aplicar(envelope) {
  this.aplicados.push(envelope);
  const payload = envelope.payload;
  if (envelope.tipo === 'ocorrencia:despachada') {
    const despacho = payload['despacho'];
    if (despacho === undefined) return;
    const id = despachoId(despacho.id);
    this.despachos.set(id, {
      despachoId: id,
      ocorrenciaId: ocorrenciaId(despacho.ocorrenciaId),
      ...(despacho.recurso?.identificador === undefined
        ? {}
        : { recurso: despacho.recurso.identificador }),
      status: despacho.status,
    });
    return;
  }
  if (envelope.tipo === 'ocorrencia:despacho-mudou-status') {
    const id = despachoId(String(payload['id']));
    const anterior = this.despachos.get(id);
    this.despachos.set(id, {
      despachoId: id,
      ocorrenciaId: ocorrenciaId(String(payload['ocorrenciaId'])),
      ...(anterior?.recurso === undefined
        ? {}
        : { recurso: anterior.recurso }),
      status: String(payload['para']),
    });
    return;
  }
  if (envelope.tipo === 'ocorrencia:mudou-status') {
    this.ocorrencias.set(
      ocorrenciaId(String(payload['id'])),
      String(payload['para']),
    );
  }
}

Garantias de entrega ​

A promessa é at-least-once. Todas as obrigações abaixo são do consumidor, e o exemplo tem um teste para cada uma.

Confira a assinatura — sobre o corpo cru ​

X-Geohub-Assinatura é sha256=<hmac> de <timestamp>.<corpo cru> com o segredo compartilhado. Confira antes de qualquer parse: conferir depois provaria a assinatura de outra coisa. A janela é de ±5 min nos dois sentidos.

ts
/**
 * HMAC-SHA256 de `<timestamp>.<corpo cru>`, comparado em tempo constante.
 *
 * O timestamp entra DENTRO do HMAC: sem isso, trocá-lo seria grátis e a
 * janela de replay deixaria de existir.
 */
private assinaturaConfere(
  cabecalhos: Record<string, string | undefined>,
  corpo: string,
): boolean {
  if (!this.janelaConfere(cabecalhos)) return false;
  const recebida = /^sha256=(?<hex>[0-9a-f]{64})$/u.exec(
    cabecalhos[CABECALHO.ASSINATURA] ?? '',
  )?.groups?.['hex'];
  if (recebida === undefined) return false;
  const esperada = createHmac('sha256', this.opcoes.segredo)
    .update(`${cabecalhos[CABECALHO.TIMESTAMP] as string}.${corpo}`, 'utf-8')
    .digest('hex');
  return timingSafeEqual(
    Buffer.from(recebida, 'hex'),
    Buffer.from(esperada, 'hex'),
  );
}
js
/**
 * HMAC-SHA256 de `<timestamp>.<corpo cru>`, comparado em tempo constante.
 *
 * O timestamp entra DENTRO do HMAC: sem isso, trocá-lo seria grátis e a
 * janela de replay deixaria de existir.
 */
assinaturaConfere(cabecalhos, corpo) {
  if (!this.janelaConfere(cabecalhos)) return false;
  const recebida = /^sha256=(?<hex>[0-9a-f]{64})$/u.exec(
    cabecalhos[CABECALHO.ASSINATURA] ?? '',
  )?.groups?.['hex'];
  if (recebida === undefined) return false;
  const esperada = createHmac('sha256', this.opcoes.segredo)
    .update(`${cabecalhos[CABECALHO.TIMESTAMP]}.${corpo}`, 'utf-8')
    .digest('hex');
  return timingSafeEqual(
    Buffer.from(recebida, 'hex'),
    Buffer.from(esperada, 'hex'),
  );
}
sh
# Conferir a assinatura no shell
printf '%s.%s' "$TS" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO"

Deduplique pelo id do envelope, e responda 204 ​

O mesmo evento chega de novo quando a resposta do seu callback se perde. Responder 204 ao repetido é o que faz o Geohub parar de insistir.

ts
/**
 * O status que volta ao Geohub é a resposta do protocolo:
 *
 * - `401` — assinatura ou janela: o Geohub não insiste com quem recusa a
 *   credencial dele;
 * - `400` — corpo que não é envelope: insistir não conserta;
 * - `204` — aceito. **Também para o repetido e para o eco**: eles foram
 *   entregues com sucesso; o que se faz com eles é decisão de quem recebe,
 *   e responder erro faria o Geohub reenviar para sempre.
 */
private async receber(req: IncomingMessage): Promise<number> {
  const corpo = await corpoCruDe(req);
  const cabecalhos = req.headers as Record<string, string | undefined>;

  // Sobre o corpo CRU e antes de qualquer parse: conferir depois provaria
  // a assinatura de outra coisa.
  if (!this.assinaturaConfere(cabecalhos, corpo)) {
    this.descartes.push({
      eventoId: cabecalhos[CABECALHO.EVENTO_ID],
      motivo: this.janelaConfere(cabecalhos)
        ? 'assinatura-invalida'
        : 'timestamp-fora-da-janela',
    });
    return 401;
  }

  const lido: unknown = JSON.parse(corpo);
  if (!ehEnvelope(lido)) {
    this.descartes.push({
      eventoId: cabecalhos[CABECALHO.EVENTO_ID],
      motivo: 'envelope-invalido',
    });
    return 400;
  }

  this.recebidos.push(lido);

  if (this.vistos.has(lido.id)) {
    this.descartes.push({ eventoId: lido.id, motivo: 'repetido' });
    return 204;
  }
  this.vistos.add(lido.id);

  /*
   * O eco é sobre fato de DOMÍNIO que este CAD causou. O desfecho
   * (`integracao:*`) também vem com a autoria dele — é assim que o Geohub
   * sabe a quem entregá-lo —, mas ele é a RESPOSTA ao comando, não o eco
   * dele: é por ele que o cliente que perdeu a resposta da chamada
   * descobre o id que nasceu (task-550). Descartá-lo seria jogar fora o
   * único retorno que o webhook traz.
   */
  if (
    lido.origem.plugin !== PLUGIN_DE_INTEGRACAO &&
    lido.origem.autoria.tipo === 'integrador' &&
    lido.origem.autoria.id === this.opcoes.integradorId
  ) {
    this.descartes.push({ eventoId: lido.id, motivo: 'eco-proprio' });
    return 204;
  }

  this.enfileirar(
    cabecalhos[CABECALHO.AGREGADO] ?? lido.id,
    Number(cabecalhos[CABECALHO.SEQUENCIA]),
    lido,
  );
  return 204;
}
js
/**
 * O status que volta ao Geohub é a resposta do protocolo:
 *
 * - `401` — assinatura ou janela: o Geohub não insiste com quem recusa a
 *   credencial dele;
 * - `400` — corpo que não é envelope: insistir não conserta;
 * - `204` — aceito. **Também para o repetido e para o eco**: eles foram
 *   entregues com sucesso; o que se faz com eles é decisão de quem recebe,
 *   e responder erro faria o Geohub reenviar para sempre.
 */
async receber(req) {
  const corpo = await corpoCruDe(req);
  const cabecalhos = req.headers;
  // Sobre o corpo CRU e antes de qualquer parse: conferir depois provaria
  // a assinatura de outra coisa.
  if (!this.assinaturaConfere(cabecalhos, corpo)) {
    this.descartes.push({
      eventoId: cabecalhos[CABECALHO.EVENTO_ID],
      motivo: this.janelaConfere(cabecalhos)
        ? 'assinatura-invalida'
        : 'timestamp-fora-da-janela',
    });
    return 401;
  }
  const lido = JSON.parse(corpo);
  if (!ehEnvelope(lido)) {
    this.descartes.push({
      eventoId: cabecalhos[CABECALHO.EVENTO_ID],
      motivo: 'envelope-invalido',
    });
    return 400;
  }
  this.recebidos.push(lido);
  if (this.vistos.has(lido.id)) {
    this.descartes.push({ eventoId: lido.id, motivo: 'repetido' });
    return 204;
  }
  this.vistos.add(lido.id);
  /*
   * O eco é sobre fato de DOMÍNIO que este CAD causou. O desfecho
   * (`integracao:*`) também vem com a autoria dele — é assim que o Geohub
   * sabe a quem entregá-lo —, mas ele é a RESPOSTA ao comando, não o eco
   * dele: é por ele que o cliente que perdeu a resposta da chamada
   * descobre o id que nasceu (task-550). Descartá-lo seria jogar fora o
   * único retorno que o webhook traz.
   */
  if (
    lido.origem.plugin !== PLUGIN_DE_INTEGRACAO &&
    lido.origem.autoria.tipo === 'integrador' &&
    lido.origem.autoria.id === this.opcoes.integradorId
  ) {
    this.descartes.push({ eventoId: lido.id, motivo: 'eco-proprio' });
    return 204;
  }
  this.enfileirar(
    cabecalhos[CABECALHO.AGREGADO] ?? lido.id,
    Number(cabecalhos[CABECALHO.SEQUENCIA]),
    lido,
  );
  return 204;
}

Ordene por X-Geohub-Sequencia, dentro do agregado ​

A sequência é sua, por agregado (ocorrencia:<id>, guarnicao:<id>, jornada:<id>): em cada agregado, o seu cadastro recebe 1, 2, 3… sem buraco. Ela não é global, nem igual para todo integrador — o mesmo evento de guarnição pode chegar a você como 4 e a outro CAD como 7 —, e o Geohub a garante contra gravações concorrentes no próprio banco.

O Geohub tenta entregar em ordem, mas um evento que falha não segura os seguintes — segurar transformaria um callback lento em fila parada para todo mundo. Então o que chega adiantado espera: o buraco na chegada é sempre um evento ainda em reenvio, e ele chega (ou fica no dead letter, de onde a operação o reenvia).

ts
/**
 * Aplica em ordem de `sequencia` **dentro do agregado**.
 *
 * A sequência é deste cadastro, por agregado: 1, 2, 3… sem buraco. O
 * Geohub tenta entregar em ordem, mas um evento que falha não segura os
 * seguintes — segurar transformaria um callback lento em fila parada.
 * Então o buraco na chegada é um evento em reenvio, e o que chega
 * adiantado espera por ele.
 *
 * Sequência ilegível (cabeçalho ausente ou adulterado) aplica na hora: o
 * cabeçalho **não** entra no HMAC, e travar a projeção por causa de um
 * metadado não provado seria entregar o controle a quem está no caminho.
 */
private enfileirar(
  agregado: string,
  sequencia: number,
  envelope: EnvelopeDoGeohub,
): void {
  if (!Number.isFinite(sequencia)) {
    this.aplicar(envelope);
    return;
  }
  const ultima = this.sequencias.get(agregado) ?? sequencia - 1;
  if (sequencia > ultima + 1) {
    const esperando = this.espera.get(agregado) ?? [];
    esperando.push(envelope);
    this.espera.set(agregado, esperando);
    return;
  }
  this.sequencias.set(agregado, sequencia);
  this.aplicar(envelope);
  this.drenar(agregado, sequencia);
}
js
/**
 * Aplica em ordem de `sequencia` **dentro do agregado**.
 *
 * A sequência é deste cadastro, por agregado: 1, 2, 3… sem buraco. O
 * Geohub tenta entregar em ordem, mas um evento que falha não segura os
 * seguintes — segurar transformaria um callback lento em fila parada.
 * Então o buraco na chegada é um evento em reenvio, e o que chega
 * adiantado espera por ele.
 *
 * Sequência ilegível (cabeçalho ausente ou adulterado) aplica na hora: o
 * cabeçalho **não** entra no HMAC, e travar a projeção por causa de um
 * metadado não provado seria entregar o controle a quem está no caminho.
 */
enfileirar(agregado, sequencia, envelope) {
  if (!Number.isFinite(sequencia)) {
    this.aplicar(envelope);
    return;
  }
  const ultima = this.sequencias.get(agregado) ?? sequencia - 1;
  if (sequencia > ultima + 1) {
    const esperando = this.espera.get(agregado) ?? [];
    esperando.push(envelope);
    this.espera.set(agregado, esperando);
    return;
  }
  this.sequencias.set(agregado, sequencia);
  this.aplicar(envelope);
  this.drenar(agregado, sequencia);
}

Descarte o próprio eco — menos o integracao:* ​

O fato de domínio que o seu CAD causou volta como evento, com origem.autoria igual à dele. Reaplicá-lo duplicaria o que ele já sabe — você recebeu o ocorrenciaId e o despachoId na resposta do comando.

O integracao:solicitacao-aceita e o -rejeitada também vêm com a sua autoria, e esses não são eco: eles são a resposta ao comando, pelo segundo caminho. É por eles que o cliente que perdeu a resposta da chamada — timeout, processo reiniciado — descobre o que aconteceu, correlacionando pelo solicitacaoId que ele próprio cunhou. Todo comando de todo plugin produz um, inclusive os da jornada.

Retry e reenvio ​

Falha de entrega vira reagendamento com backoff exponencial (de 1 min até 1 h), com teto de 10 tentativas — inclusive quando o nome do seu callback deixa de resolver no DNS. O que esgota o teto fica visível como dead letter e pode ser reenviado à mão — não se perde. O reenvio sai com a mesma X-Geohub-Sequencia de antes.

O endereço é o callback_url do seu cadastro de integrador, o único: todo evento que é seu — de ocorrência, de jornada ou o desfecho de um comando — vai para lá. Por SSRF, ele precisa ser https, na porta 443 (salvo quando a allowlist traz a porta), com o host na allowlist do seu cadastro, e resolver para endereço público.

Erros e códigos ​

Duas famílias, e confundi-las é o erro de desenho mais comum:

Recusa de protocolo — defeito do integrador. Trate como .

códigostatuso que é
NAO_AUTENTICADO401token ausente ou inválido
SEM_CAPACIDADE403o papel não alcança o verbo
GUARDA_DE_DOMINIO403o papel alcança, mas o objeto não é seu, ou faltou a referenciaExterna no registro
AUTORIA_FORJADA403o x-autoria não é o da sessão
CORPO_INVALIDO400a entrada não cumpre o contrato (com detalhes[] por campo)
SOLICITACAO_AUSENTE400comando de integrador sem x-solicitacao-id
SOLICITACAO_INVALIDA400o x-solicitacao-id não é um UUID
SOLICITACAO_EM_ANDAMENTO409o mesmo x-solicitacao-id ainda executa: repita depois do Retry-After
SOLICITACAO_REUSADA422o x-solicitacao-id já foi de outro comando
VERBO_DESCONHECIDO404verbo fora da tabela de exposição

Falha de negócio — resposta normal. Trate na tela.

códigostatuso que é
ocorrencia-inexistente / despacho-inexistente404o id não existe
jornada-inexistente / guarnicao-inexistente / pausa-inexistente404idem
transicao-invalida409o verbo não vale no status atual; detalhe diz qual era
referencia-externa-ja-registrada409o protocolo já identifica uma ocorrência sua; o id vem em contexto.ocorrenciaId
jornada-ja-ativa409o policial já tem jornada não terminal
viatura-ja-tem-guarnicao409a viatura já tem guarnição não terminal
comando-nao-assumido409prontidão antes de assumirComando
jornada-nao-iniciada / sem-posto-de-comandante409o indicado não pode assumir o comando
qualquer outro código422a requisição valia; o domínio recusou

Decida sempre pelo codigo, nunca pelo texto de mensagem. Quando a falha traz dado para seguir, ele vem em contexto — nunca no texto. No seu código isso vira um tipo: desfecho é valor, recusa é exceção.

ts
/**
 * O desfecho de um comando.
 *
 * Falha de **negócio** é valor, não exceção: "a viatura não tem guarnição
 * disponível" é uma resposta que o despachante trata na tela, não um
 * incidente. Recusa de **protocolo** (token inválido, papel que não alcança
 * o verbo, corpo fora do contrato) continua sendo exceção — ela é defeito
 * do integrador, e engoli-la num `if` faria um bug de integração parecer
 * uma regra de negócio.
 *
 * A falha é a do plugin, inteira: discriminada por `codigo`, com `contexto`
 * só onde existe. Quem lê `codigo === 'referencia-externa-ja-registrada'`
 * tem `contexto.ocorrenciaId` tipado, sem voltar ao `typeof`.
 */
export type Desfecho<T, F extends { readonly codigo: string }> =
  | ({ readonly aceito: false } & F)
  | { readonly aceito: true; readonly valor: T };
js
// #endregion desfecho
// #endregion portas
export {};

Pela REST, a tradução é sua:

ts
/** O envelope de resposta de toda rota do plugin. */
interface RespostaDoPlugin {
  readonly dados?: unknown;
  readonly erro?: {
    readonly codigo?: string;
    readonly contexto?: Readonly<Record<string, unknown>>;
    readonly mensagem?: string;
  };
}

/**
 * Os códigos que são recusa de **protocolo** — defeito do integrador, não
 * regra de negócio. Eles viram exceção; o resto vira `Desfecho`.
 */
const RECUSAS_DE_PROTOCOLO = new Set([
  'AUTORIA_FORJADA',
  'CORPO_INVALIDO',
  'ERRO_INTERNO',
  'NAO_AUTENTICADO',
  'SEM_CAPACIDADE',
  'SOLICITACAO_AUSENTE',
  'SOLICITACAO_EM_ANDAMENTO',
  'SOLICITACAO_INVALIDA',
  'SOLICITACAO_REUSADA',
  'VERBO_DESCONHECIDO',
]);

export { ErroDeProtocolo } from './erro-de-protocolo.js';

/** O erro do fio: texto até ser lido pelo contrato. */
interface ErroNoFio {
  readonly codigo: string;
  readonly contexto?: Readonly<Record<string, unknown>>;
  readonly detalhe: string;
}

/**
 * Traduz a resposta HTTP em `Desfecho`, com `ler` dando forma ao `dados` e
 * `lerFalha` dando forma à falha pelo contrato do plugin. Um código que o
 * contrato não conhece não vira desfecho: é o contrato que divergiu, e isso
 * é recusa de protocolo, como as outras.
 *
 * `GUARDA_DE_DOMINIO` fica de fora das recusas de protocolo de propósito:
 * ela é um `403` que diz "esta guarnição não é sua", e o integrador a trata
 * como resposta — não como bug.
 */
async function desfechoDe<T, F extends { readonly codigo: string }>(
  resposta: Response,
  ler: (dados: unknown) => T,
  lerFalha: (erro: ErroNoFio) => F | undefined,
): Promise<Desfecho<T, F>> {
  const corpo = (await resposta.json()) as RespostaDoPlugin;
  if (resposta.ok) return { aceito: true, valor: ler(corpo.dados) };
  const codigo = corpo.erro?.codigo ?? 'ERRO_DESCONHECIDO';
  const mensagem = corpo.erro?.mensagem ?? `HTTP ${resposta.status}`;
  if (RECUSAS_DE_PROTOCOLO.has(codigo)) {
    const retryAfter = Number(resposta.headers.get('Retry-After') ?? '');
    throw new ErroDeProtocolo(
      resposta.status,
      codigo,
      mensagem,
      Number.isInteger(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
    );
  }
  const falha = lerFalha({
    codigo,
    ...(corpo.erro?.contexto === undefined
      ? {}
      : { contexto: corpo.erro.contexto }),
    detalhe: mensagem,
  });
  if (falha === undefined) {
    throw new ErroDeProtocolo(
      resposta.status,
      codigo,
      CODIGOS_CONHECIDOS.has(codigo)
        ? `a falha ${codigo} veio sem o contexto que o contrato promete: ${mensagem}`
        : `código fora do contrato do plugin: ${mensagem}`,
    );
  }
  return { aceito: false, ...falha };
}

const CODIGOS_CONHECIDOS = new Set<string>([
  ...Object.values(CODIGO_DE_FALHA_DE_JORNADA),
  ...Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA),
]);

/** A falha de jornada: todo código do contrato se explica sozinho. */
const falhaDeJornada = (erro: ErroNoFio): FalhaDeJornada | undefined => {
  for (const codigo of Object.values(CODIGO_DE_FALHA_DE_JORNADA)) {
    if (codigo === erro.codigo) return { codigo, detalhe: erro.detalhe };
  }
  return undefined;
};

/** A falha de ocorrência: a referência repetida traz o id da que já existe. */
const falhaDeOcorrencia = (erro: ErroNoFio): FalhaDeOcorrencia | undefined => {
  if (
    erro.codigo ===
    CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
  ) {
    const existente = erro.contexto?.['ocorrenciaId'];
    return typeof existente === 'string'
      ? {
          codigo: erro.codigo,
          contexto: { ocorrenciaId: ocorrenciaId(existente) },
          detalhe: erro.detalhe,
        }
      : undefined;
  }
  for (const codigo of Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA)) {
    if (
      codigo !==
        CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA &&
      codigo === erro.codigo
    ) {
      return { codigo, detalhe: erro.detalhe };
    }
  }
  return undefined;
};
js
import {
  CODIGO_DE_FALHA_DE_JORNADA,
  guarnicaoId,
  jornadaId,
  selarGuarnicaoElegivel,
  usuarioId,
  viaturaId,
} from '@geocontrol/geohub-sdk/jornada';
import {
  CODIGO_DE_FALHA_DE_OCORRENCIA,
  despachoId,
  ocorrenciaId,
} from '@geocontrol/geohub-sdk/ocorrencia';
import { ErroDeProtocolo } from './erro-de-protocolo.js';
/**
 * Os códigos que são recusa de **protocolo** — defeito do integrador, não
 * regra de negócio. Eles viram exceção; o resto vira `Desfecho`.
 */
const RECUSAS_DE_PROTOCOLO = new Set([
  'AUTORIA_FORJADA',
  'CORPO_INVALIDO',
  'ERRO_INTERNO',
  'NAO_AUTENTICADO',
  'SEM_CAPACIDADE',
  'SOLICITACAO_AUSENTE',
  'SOLICITACAO_EM_ANDAMENTO',
  'SOLICITACAO_INVALIDA',
  'SOLICITACAO_REUSADA',
  'VERBO_DESCONHECIDO',
]);
export { ErroDeProtocolo } from './erro-de-protocolo.js';
/**
 * Traduz a resposta HTTP em `Desfecho`, com `ler` dando forma ao `dados` e
 * `lerFalha` dando forma à falha pelo contrato do plugin. Um código que o
 * contrato não conhece não vira desfecho: é o contrato que divergiu, e isso
 * é recusa de protocolo, como as outras.
 *
 * `GUARDA_DE_DOMINIO` fica de fora das recusas de protocolo de propósito:
 * ela é um `403` que diz "esta guarnição não é sua", e o integrador a trata
 * como resposta — não como bug.
 */
async function desfechoDe(resposta, ler, lerFalha) {
  const corpo = await resposta.json();
  if (resposta.ok) return { aceito: true, valor: ler(corpo.dados) };
  const codigo = corpo.erro?.codigo ?? 'ERRO_DESCONHECIDO';
  const mensagem = corpo.erro?.mensagem ?? `HTTP ${resposta.status}`;
  if (RECUSAS_DE_PROTOCOLO.has(codigo)) {
    const retryAfter = Number(resposta.headers.get('Retry-After') ?? '');
    throw new ErroDeProtocolo(
      resposta.status,
      codigo,
      mensagem,
      Number.isInteger(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
    );
  }
  const falha = lerFalha({
    codigo,
    ...(corpo.erro?.contexto === undefined
      ? {}
      : { contexto: corpo.erro.contexto }),
    detalhe: mensagem,
  });
  if (falha === undefined) {
    throw new ErroDeProtocolo(
      resposta.status,
      codigo,
      CODIGOS_CONHECIDOS.has(codigo)
        ? `a falha ${codigo} veio sem o contexto que o contrato promete: ${mensagem}`
        : `código fora do contrato do plugin: ${mensagem}`,
    );
  }
  return { aceito: false, ...falha };
}
const CODIGOS_CONHECIDOS = new Set([
  ...Object.values(CODIGO_DE_FALHA_DE_JORNADA),
  ...Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA),
]);
/** A falha de jornada: todo código do contrato se explica sozinho. */
const falhaDeJornada = (erro) => {
  for (const codigo of Object.values(CODIGO_DE_FALHA_DE_JORNADA)) {
    if (codigo === erro.codigo) return { codigo, detalhe: erro.detalhe };
  }
  return undefined;
};
/** A falha de ocorrência: a referência repetida traz o id da que já existe. */
const falhaDeOcorrencia = (erro) => {
  if (
    erro.codigo ===
    CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA
  ) {
    const existente = erro.contexto?.['ocorrenciaId'];
    return typeof existente === 'string'
      ? {
          codigo: erro.codigo,
          contexto: { ocorrenciaId: ocorrenciaId(existente) },
          detalhe: erro.detalhe,
        }
      : undefined;
  }
  for (const codigo of Object.values(CODIGO_DE_FALHA_DE_OCORRENCIA)) {
    if (
      codigo !==
        CODIGO_DE_FALHA_DE_OCORRENCIA.REFERENCIA_EXTERNA_JA_REGISTRADA &&
      codigo === erro.codigo
    ) {
      return { codigo, detalhe: erro.detalhe };
    }
  }
  return undefined;
};
// #endregion desfecho-rest
const nada = () => undefined;
const texto = (dados) => String(dados);
/**
 * O selo de elegibilidade é do Geohub: a rota só devolve guarnição
 * `disponivel`, então selar aqui é registrar que foi ele quem conferiu.
 */
const guarnicaoElegivel = (dados) => {
  if (dados === null || dados === undefined) return undefined;
  const lida = dados;
  return selarGuarnicaoElegivel({
    composicao: lida.composicao.map((i) => ({
      posto: i.posto,
      usuario: usuarioId(i.usuario),
    })),
    guarnicaoId: guarnicaoId(lida.guarnicaoId),
    viatura: viaturaId(lida.viatura),
  });
};
const url = (partes, ...valores) =>
  partes.reduce(
    (acumulado, parte, indice) =>
      acumulado +
      parte +
      (indice < valores.length ? encodeURIComponent(valores[indice]) : ''),
    '',
  );
// #region jornada-rest
/** O plugin `jornada` pelos caminhos da OpenAPI. */
export function jornadaPorRest(sessao) {
  return {
    assumirComando: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/guarnicoes/${dados.guarnicaoId}/comando`,
            { corpo: { jornadaId: dados.jornadaId }, solicitacaoId },
          ),
          nada,
          falhaDeJornada,
        ),
      ),
    constituirGuarnicao: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', '/guarnicoes', {
            corpo: dados,
            solicitacaoId,
          }),
          (id) => guarnicaoId(texto(id)),
          falhaDeJornada,
        ),
      ),
    declararProntidao: (id) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', url`/guarnicoes/${id}/prontidao`, {
            solicitacaoId,
          }),
          nada,
          falhaDeJornada,
        ),
      ),
    // Leitura não leva `solicitacaoId`: consultar não é agir, e repetir um
    // `GET` não muda nada.
    guarnicaoDisponivelNaViatura: async (viatura) => {
      const resposta = await sessao.requisitar(
        'GET',
        url`/viaturas/${viatura}/guarnicao-disponivel`,
      );
      const desfecho = await desfechoDe(
        resposta,
        guarnicaoElegivel,
        falhaDeJornada,
      );
      return desfecho.aceito ? desfecho.valor : undefined;
    },
    iniciarJornada: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', url`/jornadas/${dados.id}/inicio`, {
            corpo: { posto: dados.posto },
            solicitacaoId,
          }),
          nada,
          falhaDeJornada,
        ),
      ),
    registrarApresentacao: (usuario) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/policiais/${usuario}/apresentacao`,
            { solicitacaoId },
          ),
          (id) => jornadaId(texto(id)),
          falhaDeJornada,
        ),
      ),
  };
}
// #endregion jornada-rest
// #region ocorrencia-rest
/** O plugin `ocorrencia` pelos caminhos da OpenAPI. */
export function ocorrenciaPorRest(sessao) {
  return {
    atualizarOcorrencia: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('PATCH', url`/ocorrencias/${dados.id}`, {
            corpo: { alteracoes: dados.alteracoes },
            solicitacaoId,
          }),
          nada,
          falhaDeOcorrencia,
        ),
      ),
    despachar: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar(
            'POST',
            url`/ocorrencias/${dados.ocorrenciaId}/despachos`,
            {
              corpo: {
                ...(dados.instrucoes === undefined
                  ? {}
                  : { instrucoes: dados.instrucoes }),
                recurso: dados.recurso,
              },
              solicitacaoId,
            },
          ),
          (id) => despachoId(texto(id)),
          falhaDeOcorrencia,
        ),
      ),
    registrarOcorrencia: (dados) =>
      sessao.comando(async (solicitacaoId) =>
        desfechoDe(
          await sessao.requisitar('POST', '/ocorrencias', {
            corpo: dados,
            solicitacaoId,
          }),
          (id) => ocorrenciaId(texto(id)),
          falhaDeOcorrencia,
        ),
      ),
  };
}
// #endregion ocorrencia-rest

Pelo SDK, a falha de negócio já chega como Result e a recusa de protocolo já é exceção — só resta adaptar ao seu vocabulário:

ts
/**
 * O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
 * dado, a falha leva a falha inteira — o `codigo` discriminado e o
 * `contexto` de quem o tem.
 */
const comoDesfecho = <T, F extends { readonly codigo: string }>(
  resultado: Result<T, F>,
): Desfecho<T, F> =>
  resultado.success
    ? { aceito: true, valor: resultado.data }
    : { aceito: false, ...resultado.error };
js
/**
 * O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
 * dado, a falha leva a falha inteira — o `codigo` discriminado e o
 * `contexto` de quem o tem.
 */
const comoDesfecho = (resultado) =>
  resultado.success
    ? { aceito: true, valor: resultado.data }
    : { aceito: false, ...resultado.error };

O roteiro inteiro ​

É este o caso que o pacote de exemplo roda — duas vezes, pelo SDK e pela REST, sem um caso editado.

Rodar o exemplo ​

sh
pnpm --filter @geocontrol/exemplo-integrador-despacho test

A suíte sobe o Geohub em processo — os dois plugins com a REST gerada, os

em memória, o outbox e um emissor de webhook de verdade apontado para o

ReceptorDeEventos. Não precisa de Docker e não precisa de ambiente.