Pular para o conteúdo

C-1 — Cadastro de Cidadãos

Fase 2 — Microsserviço independente


A C-1 é a fonte da verdade da identidade do cidadão e de suas autodeclarações de perfil. Responde a duas perguntas: quem é o cidadão_id que está operando e o que esse cidadão declara sobre si?

Gerencia três providers de autenticação — device anônimo (UUID v4), login social Google (OAuth 2.0) e gov.br (OIDC) — e mantém os metadados de perfil: nome, email, avatar, endereço autodeclarado e unidade cívica de residência autodeclarada. Consome vínculo.validado da D-9 para manter projeção local do status de vínculo por UC.

A C-1 guarda a declaração. A verificação é outra camada. Quando o cidadão declara “moro na Rua X, UC Jardim das Flores”, a C-1 armazena. A D-9 (Validação de Vínculo) verifica independentemente essa declaração e publica o resultado. A C-1 projeta o resultado para exibição no perfil.

A C-1 não valida vínculo (D-9), não gerencia elegibilidade de conselheiro (D-6a) nem mandato e progressão (D-17), não toma decisão sobre quem pode fazer o quê. Apenas estabelece identidade e armazena autodeclarações.

No MVP (Fase 1), a C-1 não existe como módulo separado. Os dados de cidadão residem no estado próprio do BFF da D-1a, schema d1a. Na Fase 2, a C-1 é extraída para módulo independente com schema próprio c1. O BFF da D-1a passa a consumi-la por API REST para operações síncronas (login, atualização de perfil) e por eventos para projeções locais de leitura. Nenhuma outra colônia acessa o banco da C-1.

A C-1 expõe REST API interna — consumida pelo BFF da D-1a como proxy, não diretamente pelo front-end. O BFF recebe a requisição HTTP do front-end, roteia para a C-1 e retorna a resposta. O BFF não toma decisão sobre dados de cidadão, apenas faz proxy. A C-1 publica e consome eventos via barramento para notificações assíncronas.


A C-1 é um módulo NestJS com encapsulamento próprio dentro do monolito modular da Fase 2. Expõe controllers REST internos (consumidos pelo BFF da D-1a via HTTP local), publica eventos no barramento via EventBusService (N-0a) e consome vínculo.validado da D-9 para projeção local.

src/cidadao/c-1-cadastro-cidadaos/
├── c1.module.ts # Module definition
├── controllers/
│ ├── cidadao.controller.ts # POST /anonymous, /google, /govbr, GET/PATCH /:id
│ └── vinculo.controller.ts # GET /:id/vinculos
├── services/
│ ├── cidadao.service.ts # Core: findOrCreate, vincular, updatePerfil
│ ├── google-auth.service.ts # Verificação de token Google OAuth
│ ├── govbr-auth.service.ts # Verificação de token gov.br OIDC + JWKS
│ └── vinculo.service.ts # Handler de vínculo.validado + query de projeção
├── dto/
│ ├── criar-anonimo.dto.ts # Contrato POST /cidadaos/anonymous
│ ├── criar-google.dto.ts # Contrato POST /cidadaos/google
│ ├── criar-govbr.dto.ts # Contrato POST /cidadaos/govbr
│ ├── vincular-cidadaos.dto.ts # Contrato POST /cidadaos/vinculos
│ └── atualizar-perfil.dto.ts # Contrato PATCH /cidadaos/:id
├── entities/
│ ├── cidadao.entity.ts # Prisma entity para c1.cidadaos
│ ├── auth-provider.entity.ts # Prisma entity para c1.auth_providers
│ ├── vinculo.entity.ts # Prisma entity para c1.vinculos
│ └── consumer-offset.entity.ts # Prisma entity para c1.consumer_offset
├── repositories/
│ ├── cidadao.repository.ts # Acesso a c1.cidadaos
│ ├── auth-provider.repository.ts # Acesso a c1.auth_providers
│ ├── vinculo.repository.ts # Acesso a c1.vinculos
│ └── consumer-offset.repository.ts # Acesso a c1.consumer_offset
├── guards/
│ └── rate-limit.guard.ts # Guard que estende ThrottlerGuard com lógica por IP
└── c1.constants.ts # Constantes: limites, timeouts, cache TTL
@Module({
imports: [
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 10,
}]),
],
controllers: [
CidadaoController,
VinculoController,
],
providers: [
CidadaoService,
GoogleAuthService,
GovbrAuthService,
VinculoService,
CidadaoRepository,
AuthProviderRepository,
VinculoRepository,
ConsumerOffsetRepository,
],
exports: [],
})
export class C1Module implements OnModuleInit {
constructor(
private readonly vinculoService: VinculoService,
) {}
async onModuleInit() {
await this.vinculoService.iniciar();
}
}
  • O módulo não é @Global(). A C-1 não é dependência de nenhuma outra colônia.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O módulo não importa RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento do publicar().
  • O módulo importa ThrottlerModule.forRoot() com configuração de fallback para os endpoints REST internos. O RateLimitGuard customizado sobrescreve por rota. Na Fase 2, como os endpoints são internos (consumidos apenas pelo BFF), o rate limiting é conservador — proteção contra loops internos, não contra abuso de usuário final.
  • O OnModuleInit dispara o protocolo de inicialização do VinculoService: replay de eventos perdidos (vínculo.validado) + registro de handler.
  • O módulo registra consumer offset — a C-1 consome vínculo.validado e precisa de cursor de replay.
  • O módulo não registra handlers para eventos que não existem na Fase 1. O handler de vínculo.validado só é registrado quando a D-9 estiver implementada. Antes disso, onModuleInit apenas inicializa a estrutura sem registrar consumers.
interface ICidadaoService {
findOrCreateAnonymous(deviceId: string): Promise<{ cidadao: Cidadao; isNew: boolean }>;
findOrCreateFromGoogle(idToken: string): Promise<{ cidadao: Cidadao; isNew: boolean }>;
findOrCreateFromGovbr(idToken: string): Promise<{ cidadao: Cidadao; isNew: boolean }>;
vincularDispositivos(origemId: string, destinoId: string): Promise<void>;
findById(cidadaoId: string): Promise<Cidadao | null>;
updatePerfil(cidadaoId: string, dto: AtualizarPerfilDto): Promise<Cidadao>;
getVinculos(cidadaoId: string): Promise<Vinculo[]>;
}
interface IGoogleAuthService {
verifyToken(idToken: string): Promise<GoogleTokenPayload>;
}
interface IGovbrAuthService {
verifyToken(idToken: string): Promise<GovbrTokenPayload>;
}
interface IVinculoService {
iniciar(): Promise<void>;
onVinculoValidado(event: EventLog): Promise<void>;
}

Além dos serviços, o VinculoService depende do ConsumerOffsetRepository:

interface IConsumerOffsetRepository {
seed(tipos: string[], sequenciaInicial: number | bigint): Promise<void>;
buscarTodos(): Promise<Array<{ tipo_evento: string; last_sequence: number }>>;
upsert(tipoEvento: string, lastSequence: bigint): Promise<void>;
}
Método Rota Controller Descrição
POST /api/c1/cidadaos/anonymous CidadaoController Cria ou retorna cidadão anônimo por device ID.
POST /api/c1/cidadaos/google CidadaoController Cria ou retorna cidadão a partir de token Google.
POST /api/c1/cidadaos/govbr CidadaoController Cria ou retorna cidadão a partir de token gov.br.
POST /api/c1/cidadaos/vinculos CidadaoController Vincula duas identidades (ex: anônimo → Google).
GET /api/c1/cidadaos/:id CidadaoController Retorna perfil completo do cidadão com providers.
PATCH /api/c1/cidadaos/:id CidadaoController Atualiza endereço e UC de residência.
GET /api/c1/cidadaos/:id/vinculos VinculoController Retorna status de vínculo por UC.

Os endpoints são prefixados com /api/c1/ para isolar o namespace da C-1 no roteamento do monolito. Na Fase 2, quando a C-1 for extraída para microsserviço independente, o prefixo pode ser removido ou mantido conforme a configuração de API gateway.

A C-1 é uma colônia com BFF acoplado — expõe endpoints REST internos. Diferente da D-1a (BFF de entrada do sistema, voltado ao front-end), a C-1 expõe endpoints consumidos pelo BFF da D-1a como proxy. O front-end nunca chama a C-1 diretamente.

Essa separação é arquitetural, não de runtime. No monolito, ambos os módulos coexistem no mesmo processo. A chamada do BFF para a C-1 é HTTP local (localhost). Quando a C-1 for extraída para microsserviço, a única mudança é o destino da chamada HTTP — de localhost para o endereço do serviço.

A C-1 não faz chamadas HTTP para outras colônias. Comunicação assíncrona é via barramento de eventos. A C-1 não atua como proxy para nenhuma outra colônia.


Todas as tabelas da C-1 residem no schema c1 do PostgreSQL. Este schema é de uso exclusivo do módulo C-1. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro de identidade do cidadão. Contém apenas dados de perfil e autodeclarações. Providers de autenticação e status de vínculo são tabelas separadas.

CREATE SCHEMA IF NOT EXISTS c1;
CREATE TABLE c1.cidadaos (
id UUID PRIMARY KEY,
nome VARCHAR(200),
email VARCHAR(255),
avatar_url VARCHAR(500),
endereco VARCHAR(500),
uc_residencia UUID,
vinculado_a UUID,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_c1_cidadaos_email ON c1.cidadaos (email)
WHERE email IS NOT NULL;
CREATE INDEX idx_c1_cidadaos_vinculado ON c1.cidadaos (vinculado_a)
WHERE vinculado_a IS NOT NULL;
Coluna Tipo Descrição
id UUID PK cidadão_id usado em todo o sistema. Para device anônimo, é o próprio UUID gerado pelo front-end. Para Google e gov.br, é um novo UUID gerado pela C-1.
nome VARCHAR(200) Nome de exibição. Do Google ou gov.br. Nulo para device anônimo.
email VARCHAR(255) Email principal. Do Google ou gov.br. Nulo para device anônimo.
avatar_url VARCHAR(500) URL do avatar. Do Google. Nulo para gov.br e anônimo.
endereco VARCHAR(500) Endereço autodeclarado pelo cidadão. Nulo até preenchimento.
uc_residencia UUID unidade_cívica_id de residência autodeclarada. Nulo até preenchimento.
vinculado_a UUID Se esta identidade foi vinculada a outra, aponta para o cidadão_id de destino. GET por este ID retorna 301 para o destino.
criado_em TIMESTAMPTZ Data de criação do registro.
atualizado_em TIMESTAMPTZ Data da última alteração de perfil.

Mapeamento entre um cidadão e seus providers de autenticação. Um cidadão pode ter múltiplos providers (ex: começou como anônimo, vinculou Google e gov.br). Apenas um provider é marcado como primário.

CREATE TABLE c1.auth_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cidadao_id UUID NOT NULL,
provider VARCHAR(20) NOT NULL,
provider_sub VARCHAR(255),
provider_data JSONB NOT NULL DEFAULT '{}',
is_primary BOOLEAN NOT NULL DEFAULT false,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_c1_provider_tipo
CHECK (provider IN ('anonymous', 'google', 'govbr')),
CONSTRAINT uq_c1_provider_sub
UNIQUE (provider, provider_sub),
CONSTRAINT fk_c1_providers_cidadao
FOREIGN KEY (cidadao_id) REFERENCES c1.cidadaos(id)
);
CREATE INDEX idx_c1_providers_cidadao ON c1.auth_providers (cidadao_id);
CREATE INDEX idx_c1_providers_lookup ON c1.auth_providers (provider, provider_sub)
WHERE provider_sub IS NOT NULL;
Coluna Tipo Descrição
id UUID PK Identificador interno do mapeamento.
cidadao_id UUID FK FK para c1.cidadaos.id.
provider VARCHAR(20) anonymous, google ou govbr. CHECK constraint no banco.
provider_sub VARCHAR(255) Identificador único do usuário no provider. Para Google e gov.br, é o sub do token OIDC. Nulo para anonymous.
provider_data JSONB Dados adicionais do provider. Para gov.br: { "cpf": "...", "phone_number": "..." }. Para Google: {}.
is_primary BOOLEAN Se este é o provider principal do cidadão. Apenas um por cidadão.
criado_em TIMESTAMPTZ Data de criação do mapeamento.

Nota sobre uq_c1_provider_sub: PostgreSQL trata múltiplos NULLs como distintos em constraints UNIQUE. Linhas com provider='anonymous' e provider_sub=NULL não conflitam entre si — cada device anônimo terá seu próprio cidadao_id e, portanto, seu próprio auth_provider distinto. O índice parcial idx_c1_providers_lookup cobre apenas buscas por (provider, provider_sub) com provider_sub IS NOT NULL.

Projeção local do status de vínculo do cidadão com cada unidade cívica. Alimentada exclusivamente pelo evento vínculo.validado da D-9 (Fase 2). É uma tabela de leitura. A C-1 não escreve por iniciativa própria.

CREATE TABLE c1.vinculos (
cidadao_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
status VARCHAR(20) NOT NULL,
metodo_validacao VARCHAR(50),
data_validade TIMESTAMPTZ,
hash_evidencia VARCHAR(128),
event_id_origem UUID NOT NULL,
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_c1_vinculo_status
CHECK (status IN ('valido', 'invalido', 'provisorio')),
CONSTRAINT uq_c1_vinculo_cidadao_uc
UNIQUE (cidadao_id, unidade_civica_id)
);
CREATE INDEX idx_c1_vinculos_cidadao ON c1.vinculos (cidadao_id);
CREATE INDEX idx_c1_vinculos_status ON c1.vinculos (cidadao_id, status);
Coluna Tipo Descrição
cidadao_id UUID FK lógica para c1.cidadaos.id.
unidade_civica_id UUID UC do vínculo.
status VARCHAR(20) valido, invalido ou provisorio.
metodo_validacao VARCHAR(50) Método usado: documental, social_pares, presenca_verificada.
data_validade TIMESTAMPTZ Data de expiração do vínculo. Nulo se permanente.
hash_evidencia VARCHAR(128) Hash da evidência documental (não o documento).
event_id_origem UUID event_id do vínculo.validado que originou esta linha. Para idempotência.
atualizado_em TIMESTAMPTZ Data da última atualização.

Nota sobre ausência de FK: A FK para c1.cidadaos não é criada como constraint formal. Se um cidadão for removido (LGPD), a linha em vínculos permanece como registro histórico de que aquele cidadão_id teve vínculo validado. A integridade referencial é garantida em aplicação: o handler de vínculo.validado verifica que o cidadao_id existe antes de upsertar.

Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).

CREATE TABLE c1.consumer_offset (
tipo_evento VARCHAR(255) PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

No MVP da Fase 2, a tabela tem 1 linha: vínculo.validado.

Quatro migrations iniciais:

  1. V001 — Create schema c1 and cidadaos — Cria schema c1, tabela c1.cidadaos com índices.
  2. V002 — Create auth_providers — Cria c1.auth_providers com FK para c1.cidadaos, constraint UNIQUE e índices.
  3. V003 — Create vinculos — Cria c1.vinculos com constraint UNIQUE e índices.
  4. V004 — Create consumer_offset — Cria c1.consumer_offset com PK em tipo_evento e seed inicial.

Migrations futuras (Fase 3): índices GIN em provider_data para consulta por CPF, índices compostos para queries de agregação de vínculo por UC.

A única FK formal é auth_providers.cidadao_id → cidadaos.id. As demais — vínculos.cidadao_id, cidadaos.vinculado_a — são correlações lógicas sem constraint. O motivo: na Fase 3 a tabela vinculos pode ser movida para leitura em cache Redis com write-through a partir do barramento, e constraints seriam obstáculo.

auth_providers como tabela separada de cidadaos. No MVP (D-1a), a tabela d1a.cidadaos tem as colunas auth_provider e google_sub, sem govbr_sub. Para a Fase 2, com três providers e a possibilidade de um cidadão ter múltiplos (anônimo vinculado a Google e gov.br), colunas na tabela principal não escalam. A tabela de junção permite N providers por cidadão, consulta por provider_sub indexada e adição de novos providers sem alterar o schema de c1.cidadaos.

vinculado_a em c1.cidadaos em vez de soft-delete. Quando um cidadão anônimo vincula a uma conta Google, a identidade anônima não é removida. Demandas e outros registros em colônias externas referenciam o cidadão_id original. Soft-delete quebraria essas referências. O campo vinculado_a é um redirect: GET /cidadaos/:id para um ID vinculado retorna 301 com o destino, e internamente a C-1 trata o ID de origem como alias do destino.

id do cidadão gerado pela aplicação, não pelo banco. Para device anônimo, o id é o UUID v4 gerado pelo front-end. Para Google e gov.br, é um novo UUID v4 gerado pelo CidadaoService. Gerar no banco com gen_random_uuid() criaria uma assimetria: anônimos teriam ID do front-end, Google teriam ID do banco. A aplicação é a única fonte de cidadão_id.

Ausência de FK de vínculos para cidadaos. A D-9 publica vínculo.validado com cidadão_id. Se a C-1 receber o evento antes do cidadão estar cadastrado (eventos fora de ordem), o handler não rejeita o evento. A ausência de FK permite o upsert sem validação de integridade no banco, e a linha fica armazenada até o cadastro chegar.

provider_data como JSONB. gov.br retorna campos específicos (CPF, phone_number) que não existem no Google. Colunas fixas para cada provider poluiriam o schema. JSONB permite que cada provider armazene seus dados específicos sem alteração de schema. Consultas por CPF podem usar índice GIN quando necessário (Fase 3).


A C-1 publica três tipos de evento e consome um. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da C-1.

Os contratos de evento são os mesmos do MVP. A migração da D-1a para a C-1 é transparente para os consumidores: o campo origem no envelope muda de D-1a para C-1 e a C-1 passa a preencher campos opcionais que o BFF não enviava (nome, email e avatar_url no cadastro e no perfil atualizado). O tipo e o schema não mudam.

Propriedade Valor
Tipo cidadão.cadastrado
Schema version 1.0.0
Produtor C-1 (Fase 2) / D-1a (Fase 1)
Consumidores D-7 (Transparência), colônias com projeção de perfil
Descrição Novo cidadão registrado. Identidade básica estabelecida.

Payload publicado:

interface CidadaoCadastradoPayload {
cidadao_id: string;
nome?: string;
email?: string;
avatar_url?: string;
auth_provider: string; // 'anonymous' | 'google' | 'govbr'
}
Propriedade Valor
Tipo cidadão.perfil_atualizado
Schema version 1.0.0
Produtor C-1 (Fase 2) / D-1a (Fase 1)
Consumidores Colônias com projeção local de perfil, D-7 (Transparência)
Descrição Cidadão alterou dados de perfil (endereço ou UC de residência).

Payload publicado:

interface CidadaoPerfilAtualizadoPayload {
cidadao_id: string;
campos_alterados: string[]; // ex: ['endereco', 'uc_residencia']
nome?: string;
email?: string;
avatar_url?: string;
endereco?: string;
uc_residencia?: string;
}
Propriedade Valor
Tipo cidadão.vinculado
Schema version 1.0.0
Produtor C-1 (Fase 2) / D-1a (Fase 1)
Consumidores D-7 (Transparência), colônias com projeção de perfil
Descrição Uma identidade foi vinculada a outra (ex: device anônimo → conta Google).

Payload publicado:

interface CidadaoVinculadoPayload {
cidadao_id_origem: string;
cidadao_id_destino: string;
auth_provider: string; // provider do destino (para quem foi vinculado)
}
Propriedade Valor
Tipo vínculo.validado
Schema version 1.0.0
Produtor D-9 (Validação de Vínculo — Fase 2)
Consumidor C-1 (esta colônia — projeção local)
Descrição Resultado de validação de vínculo de um cidadão com uma unidade cívica.

Payload esperado (conforme Registry N-0b, v1.0.0):

interface VinculoValidadoPayload {
cidadao_id: string;
unidade_civica_id: string;
status: string; // 'valido' | 'invalido' | 'provisorio'
metodo_validacao: string; // 'documental' | 'social_pares' | 'presenca_verificada'
data_validade?: string; // ISO-8601, se provisório
hash_evidencia?: string; // hash SHA-256 do documento (sem o documento)
}

3.5 Ordem de operações — findOrCreateAnonymous

Seção intitulada “3.5 Ordem de operações — findOrCreateAnonymous”

O fluxo no CidadaoService.findOrCreateAnonymous() segue esta ordem exata:

1. BFF (D-1a) recebe requisição do front-end com header X-Cidadao-Id
→ BFF faz POST /api/c1/cidadaos/anonymous { device_id: "<UUID>" }
2. C1Controller recebe a requisição
→ validar DTO: device_id deve ser UUID v4 válido
→ se inválido: HTTP 400
3. CidadaoService.findOrCreateAnonymous(deviceId)
→ consultar c1.cidadaos WHERE id = deviceId
→ se encontrado:
→ verificar se vinculado_a IS NOT NULL
→ se sim: retornar { cidadao: destino, isNew: false }
→ se não: retornar { cidadao: encontrado, isNew: false }
→ se não encontrado:
→ PERSISTIR em transação:
→ INSERT INTO c1.cidadaos (id) VALUES (deviceId)
→ INSERT INTO c1.auth_providers (cidadao_id, provider, is_primary)
VALUES (deviceId, 'anonymous', true)
→ PUBLICAR cidadão.cadastrado
payload: { cidadao_id: deviceId, auth_provider: 'anonymous' }
→ retornar { cidadao: novo, isNew: true }
4. HTTP 201 (se novo) ou 200 (se existente)
→ Body: { cidadao_id, nome: null, email: null, auth_providers: ['anonymous'], is_new }

3.6 Ordem de operações — findOrCreateFromGoogle

Seção intitulada “3.6 Ordem de operações — findOrCreateFromGoogle”
1. BFF recebe requisição do front-end com Google ID token
→ BFF faz POST /api/c1/cidadaos/google { id_token: "<token>" }
2. C1Controller recebe a requisição
→ validar DTO: id_token não vazio
→ se inválido: HTTP 400
3. GoogleAuthService.verifyToken(idToken)
→ google-auth-library verifica assinatura, audience, expiration
→ audience deve corresponder a GOOGLE_CLIENT_ID configurado
→ se inválido: HTTP 401 "Token Google inválido ou expirado"
→ se válido: extrai { sub, email, name, picture }
4. CidadaoService.findOrCreateFromGoogle(payload)
→ consultar c1.auth_providers
WHERE provider = 'google' AND provider_sub = payload.sub
JOIN c1.cidadaos ON cidadaos.id = auth_providers.cidadao_id
→ se encontrado:
→ verificar se o cidadão está vinculado_a outro:
→ se sim: retornar o destino
→ se não: retornar o encontrado
→ atualizar metadados (nome, email, avatar podem ter mudado no Google):
→ UPDATE c1.cidadaos SET nome=?, email=?, avatar_url=?, atualizado_em=NOW()
→ se não encontrado:
→ gerar novo UUID v4 para cidadão
→ PERSISTIR em transação:
→ INSERT INTO c1.cidadaos (id, nome, email, avatar_url)
→ INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary)
→ PUBLICAR cidadão.cadastrado
payload: { cidadao_id, nome, email, avatar_url, auth_provider: 'google' }
5. HTTP 201 (se novo) ou 200 (se existente)
→ Body: { cidadao_id, nome, email, avatar_url, auth_providers: ['google'], is_new }

3.7 Ordem de operações — findOrCreateFromGovbr

Seção intitulada “3.7 Ordem de operações — findOrCreateFromGovbr”
1. BFF recebe do front-end o resultado do fluxo OIDC gov.br
(authorization code trocado por tokens no BFF)
→ BFF faz POST /api/c1/cidadaos/govbr { id_token: "<token>" }
2. C1Controller recebe a requisição
→ validar DTO: id_token não vazio
→ se inválido: HTTP 400
3. GovbrAuthService.verifyToken(idToken)
→ obter JWKS do endpoint de chaves públicas do gov.br
(https://sso.acesso.gov.br/jwks)
→ verificar assinatura do token com chave pública
→ verificar claims: iss, aud, exp, iat
→ aud deve corresponder ao client_id registrado
→ se inválido: HTTP 401 "Token gov.br inválido ou expirado"
→ se válido: extrai { sub, name, email, cpf, phone_number }
4. CidadaoService.findOrCreateFromGovbr(payload)
→ consultar c1.auth_providers
WHERE provider = 'govbr' AND provider_sub = payload.sub
→ se encontrado:
→ verificar vinculado_a, retornar destino se aplicável
→ atualizar metadados (nome, email, provider_data com CPF e phone)
→ se não encontrado:
→ gerar novo UUID v4
→ PERSISTIR em transação:
→ INSERT INTO c1.cidadaos (id, nome, email)
→ INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary, provider_data)
VALUES (?, 'govbr', ?, true, '{ "cpf": "...", "phone_number": "..." }')
→ PUBLICAR cidadão.cadastrado
payload: { cidadao_id, nome, email, auth_provider: 'govbr' }
5. HTTP 201 ou 200

Nota sobre CPF: O CPF é armazenado em provider_data (JSONB), não em coluna dedicada. O campo nome e email vão para c1.cidadaos. A justificativa: CPF é dado sensível com requisitos específicos de LGPD. Mantê-lo em JSONB permite criptografia seletiva da coluna no futuro (pgcrypto) sem alterar schema. O acesso é restrito ao GovbrAuthService e a consultas administrativas autorizadas.

1. BFF recebe requisição de vinculação do front-end
(após login Google, com X-Cidadao-Id do device anônimo)
→ BFF faz POST /api/c1/cidadaos/vinculos
{ cidadao_origem_id, cidadao_destino_id }
2. C1Controller recebe a requisição
→ validar DTO: ambos os campos são UUID v4
→ se inválido: HTTP 400
3. CidadaoService.vincularDispositivos(origemId, destinoId)
→ buscar origem: cidadaoRepo.findById(origemId)
se não encontrado: HTTP 404 "Cidadão de origem não encontrado"
se origem.vinculado_a IS NOT NULL: HTTP 409 "Cidadão já vinculado"
→ buscar destino: cidadaoRepo.findById(destinoId)
se não encontrado: HTTP 404 "Cidadão de destino não encontrado"
→ PERSISTIR em transação:
→ Migrar auth_providers da origem para o destino:
UPDATE c1.auth_providers
SET cidadao_id = destinoId, is_primary = false
WHERE cidadao_id = origemId
→ Marcar origem como vinculada:
UPDATE c1.cidadaos
SET vinculado_a = destinoId, atualizado_em = NOW()
WHERE id = origemId
→ Garantir que há exatamente um provider primário no destino:
se todos os providers do destino têm is_primary = false:
UPDATE c1.auth_providers SET is_primary = true
WHERE cidadao_id = destinoId
ORDER BY criado_em DESC LIMIT 1
→ PUBLICAR cidadão.vinculado
eventBus.publicar({
tipo: 'cidadão.vinculado',
origem: 'C-1',
event_id: UUID v4,
payload: {
cidadao_id_origem: origemId,
cidadao_id_destino: destinoId,
auth_provider: 'google' | 'govbr', // provider do destino
}
})
4. HTTP 200 { vinculado: true, cidadao_id: destinoId }
1. BFF recebe PATCH /cidadaos/me do front-end
→ BFF faz PATCH /api/c1/cidadaos/:id { endereco?, uc_residencia? }
2. C1Controller recebe a requisição
→ validar DTO:
→ endereco: opcional, max 500 caracteres
→ uc_residencia: opcional, UUID v4
→ ao menos um campo deve estar presente
→ se inválido: HTTP 400
3. CidadaoService.updatePerfil(cidadaoId, dto)
→ buscar cidadão: cidadaoRepo.findById(cidadaoId)
se não encontrado: HTTP 404
se vinculado_a IS NOT NULL:
→ redirecionar atualização para o destino
cidadaoId = cidadao.vinculado_a
→ determinar campos alterados:
campos = []
se dto.endereco != cidadao.endereco: campos.push('endereco')
se dto.uc_residencia != cidadao.uc_residencia: campos.push('uc_residencia')
→ se campos vazio: HTTP 200, body com cidadão atual (sem publicar evento)
→ PERSISTIR:
UPDATE c1.cidadaos
SET endereco = COALESCE(dto.endereco, cidadao.endereco),
uc_residencia = COALESCE(dto.uc_residencia, cidadao.uc_residencia),
atualizado_em = NOW()
WHERE id = cidadaoId
→ PUBLICAR cidadão.perfil_atualizado
payload: {
cidadao_id: cidadaoId,
campos_alterados: campos,
endereco: dto.endereco ?? cidadao.endereco,
uc_residencia: dto.uc_residencia ?? cidadao.uc_residencia,
nome: cidadao.nome,
email: cidadao.email,
avatar_url: cidadao.avatar_url,
}
4. HTTP 200 { cidadao_id, endereco, uc_residencia, ... }

3.10 Ordem de operações — handler de vínculo.validado

Seção intitulada “3.10 Ordem de operações — handler de vínculo.validado”
1. Event Bus entrega vínculo.validado ao handler registrado
2. VinculoService.onVinculoValidado(event)
→ verificar idempotência:
SELECT FROM c1.vinculos WHERE event_id_origem = event.event_id
se encontrado: log.info, retornar
→ validar payload mínimo:
cidadao_id, unidade_civica_id, status — obrigatórios
se ausente: log.error, retornar
→ upsert em c1.vinculos:
INSERT INTO c1.vinculos (cidadao_id, unidade_civica_id, status,
metodo_validacao, data_validade, hash_evidencia, event_id_origem)
VALUES (?, ?, ?, ?, ?, ?, event.event_id)
ON CONFLICT (cidadao_id, unidade_civica_id) DO UPDATE
SET status = EXCLUDED.status,
metodo_validacao = EXCLUDED.metodo_validacao,
data_validade = EXCLUDED.data_validade,
hash_evidencia = EXCLUDED.hash_evidencia,
event_id_origem = EXCLUDED.event_id_origem,
atualizado_em = NOW()
→ atualizar consumer offset:
UPDATE c1.consumer_offset SET last_sequence = event.sequence_number
WHERE tipo_evento = 'vínculo.validado'

O upsert com ON CONFLICT ... DO UPDATE garante que múltiplas validações para o mesmo par (cidadao_id, unidade_civica_id) atualizem a linha existente em vez de gerar duplicata. O último evento recebido é o estado corrente.

A C-1 implementa idempotência em duas camadas:

Camada 1 — Aplicação (C-1): Para cidadão.cadastrado, a idempotência é natural: o método findOrCreate* primeiro consulta o banco. Se o cidadão já existe, retorna sem publicar evento. Se o INSERT falhar por violação de unicidade (race condition em auth_providers.provider_sub), o método captura a exceção, busca o registro existente e retorna sem publicar.

Camada 2 — Barramento (N-0a): O EventBusService.publicar() usa event_id como chave de idempotência. Se a C-1 chamar publicar() com um evento que já foi publicado (mesmo event_id), o barramento retorna o registro existente sem reemitir aos consumidores.

Cenário Comportamento
Token Google expirado ou inválido HTTP 401. GoogleAuthService propaga TokenExpiredError. Nenhum efeito colateral.
Token gov.br expirado ou inválido HTTP 401. GovbrAuthService propaga erro de verificação.
Provider sub já existe (Google/gov.br) Cidadão existente retornado. HTTP 200. Nenhum evento publicado.
cidadao_id de origem não encontrado em vinculação HTTP 404.
cidadao_id de destino não encontrado em vinculação HTTP 404.
Vinculação de cidadão já vinculado HTTP 409. vinculado_a IS NOT NULL.
vínculo.validado para cidadao_id inexistente Upsert realizado mesmo assim; a projeção pode ser populada antes do cadastro em eventos fora de ordem.
vínculo.validado reentregue (replay/DLQ) Detectado por event_id_origem. Log.info, retorna.
INSERT em auth_providers falha com unique violation Indica race condition. Captura exceção, busca registro existente, retorna sucesso.
eventBus.publicar() falha Log.error. Estado já persistido. Cidadão funcional mesmo sem evento publicado. Outras colônias recuperam a projeção via replay do event_log ou reconciliação.
Rate limit excedido HTTP 429. Retry-After header.

Fire-and-forget para eventos de cidadão. Diferente do pipeline de demandas, onde demanda.recebida dispara uma cadeia de processamento obrigatória, os eventos de cidadão (cadastrado, perfil_atualizado, vinculado) são notificações. Se falharem, o cidadão está funcional. Outras colônias podem não ter a projeção atualizada, mas isso é recuperável via replay do event_log ou via scheduled job de reconciliação. O trade-off é disponibilidade do cadastro vs. consistência de projeções. Para o perfil de uso (cidadão quer usar o sistema imediatamente após cadastro), disponibilidade vence.

vínculo.validado upsert mesmo com cidadao_id inexistente. Eventos podem chegar fora de ordem: a D-9 pode publicar vínculo.validado antes de a C-1 ter processado cidadão.cadastrado. Rejeitar o evento criaria uma lacuna que exigiria reentrega. O upsert armazena o dado e a aplicação consulta c1.vinculos pelo cidadao_id apenas quando o cidadão existir. O endpoint GET /cidadaos/:id/vinculos faz o join naturalmente — se o cidadão não existe, retorna 404. Se existe mas o vínculo foi armazenado antes do cadastro, retorna o vínculo normalmente.

endereco e uc_residencia como campos editáveis; nome, email, avatar_url como somente-leitura. nome, email e avatar_url são gerenciados pelo provider (Google, gov.br) e atualizados no momento do login. O cidadão pode alterar endereço e UC de residência a qualquer momento porque são autodeclarações, não dados verificados. A verificação dessas declarações é responsabilidade da D-9.

gov.br — CPF em provider_data, não em coluna dedicada. CPF é dado sensível. Coluna dedicada facilita queries, mas também facilita vazamentos acidentais (SELECT *). JSONB permite:

  • Criptografia seletiva da coluna com pgcrypto
  • Auditoria de acesso: qualquer query que acessa provider_data->>'cpf' é visível no log
  • Adição de novos campos do gov.br sem migration

4.1 CidadaoService.findOrCreateAnonymous() — pseudocódigo

Seção intitulada “4.1 CidadaoService.findOrCreateAnonymous() — pseudocódigo”
função findOrCreateAnonymous(deviceId: string) -> { cidadao: Cidadao, isNew: boolean }:
// 1. Validar formato
se !isValidUUID(deviceId):
lançar BadRequestException("device_id inválido")
// 2. Buscar cidadão existente
cidadao = cidadaoRepo.findById(deviceId)
se cidadao não é null:
// Verificar redirecionamento de identidade vinculada
se cidadao.vinculado_a não é null:
destino = cidadaoRepo.findById(cidadao.vinculado_a)
retornar { cidadao: destino, isNew: false }
retornar { cidadao, isNew: false }
// 3. Criar novo cidadão anônimo
EM TRANSAÇÃO:
cidadao = cidadaoRepo.insert({
id: deviceId,
// nome, email, avatar_url, endereco, uc_residencia = null
})
authProviderRepo.insert({
cidadao_id: deviceId,
provider: 'anonymous',
provider_sub: null,
provider_data: {},
is_primary: true,
})
// 4. Publicar evento (fire-and-forget)
tentar:
await eventBus.publicar({
tipo: 'cidadão.cadastrado',
origem: 'C-1',
event_id: deviceId,
correlacao_id: deviceId,
payload: {
cidadao_id: deviceId,
auth_provider: 'anonymous',
},
})
capturar erro:
log.error("Falha ao publicar cidadão.cadastrado (anônimo)", erro, {
cidadao_id: deviceId,
})
retornar { cidadao, isNew: true }

4.2 CidadaoService.findOrCreateFromGoogle() — pseudocódigo

Seção intitulada “4.2 CidadaoService.findOrCreateFromGoogle() — pseudocódigo”
função findOrCreateFromGoogle(payload: GoogleTokenPayload)
-> { cidadao: Cidadao, isNew: boolean }:
// 1. Buscar por provider_sub
authProvider = authProviderRepo.findByProviderAndSub('google', payload.sub)
se authProvider não é null:
cidadao = cidadaoRepo.findById(authProvider.cidadao_id)
// Redirecionar se vinculado
se cidadao.vinculado_a não é null:
cidadao = cidadaoRepo.findById(cidadao.vinculado_a)
// Atualizar metadados do Google (podem ter mudado)
cidadaoRepo.update(cidadao.id, {
nome: payload.name ?? cidadao.nome,
email: payload.email ?? cidadao.email,
avatar_url: payload.picture ?? cidadao.avatar_url,
atualizado_em: now(),
})
retornar { cidadao: cidadaoRepo.findById(cidadao.id), isNew: false }
// 2. Criar novo cidadão Google
novoId = UUIDv4()
EM TRANSAÇÃO:
cidadao = cidadaoRepo.insert({
id: novoId,
nome: payload.name,
email: payload.email,
avatar_url: payload.picture,
})
authProviderRepo.insert({
cidadao_id: novoId,
provider: 'google',
provider_sub: payload.sub,
provider_data: {},
is_primary: true,
})
// 3. Publicar evento
tentar:
await eventBus.publicar({
tipo: 'cidadão.cadastrado',
origem: 'C-1',
event_id: novoId,
correlacao_id: novoId,
payload: {
cidadao_id: novoId,
nome: payload.name,
email: payload.email,
avatar_url: payload.picture,
auth_provider: 'google',
},
})
capturar erro:
log.error("Falha ao publicar cidadão.cadastrado (Google)", erro, {
cidadao_id: novoId,
})
retornar { cidadao, isNew: true }

4.3 CidadaoService.findOrCreateFromGovbr() — pseudocódigo

Seção intitulada “4.3 CidadaoService.findOrCreateFromGovbr() — pseudocódigo”
função findOrCreateFromGovbr(payload: GovbrTokenPayload)
-> { cidadao: Cidadao, isNew: boolean }:
// Fluxo idêntico ao Google, com diferenças:
// 1. provider = 'govbr'
// 2. provider_data contém CPF e phone_number
// 3. avatar_url não é preenchido (gov.br não retorna avatar)
authProvider = authProviderRepo.findByProviderAndSub('govbr', payload.sub)
se authProvider não é null:
cidadao = cidadaoRepo.findById(authProvider.cidadao_id)
se cidadao.vinculado_a não é null:
cidadao = cidadaoRepo.findById(cidadao.vinculado_a)
cidadaoRepo.update(cidadao.id, {
nome: payload.name ?? cidadao.nome,
email: payload.email ?? cidadao.email,
atualizado_em: now(),
})
// Atualizar provider_data
authProviderRepo.updateProviderData(authProvider.id, {
cpf: payload.cpf,
phone_number: payload.phone_number,
})
retornar { cidadao: cidadaoRepo.findById(cidadao.id), isNew: false }
// Criar novo
novoId = UUIDv4()
EM TRANSAÇÃO:
cidadao = cidadaoRepo.insert({
id: novoId,
nome: payload.name,
email: payload.email,
})
authProviderRepo.insert({
cidadao_id: novoId,
provider: 'govbr',
provider_sub: payload.sub,
provider_data: {
cpf: payload.cpf,
phone_number: payload.phone_number,
},
is_primary: true,
})
// Publicar evento (fire-and-forget)
tentar:
await eventBus.publicar({
tipo: 'cidadão.cadastrado',
origem: 'C-1',
event_id: novoId,
correlacao_id: novoId,
payload: {
cidadao_id: novoId,
nome: payload.name,
email: payload.email,
auth_provider: 'govbr',
},
})
capturar erro:
log.error(...)
retornar { cidadao, isNew: true }

4.4 CidadaoService.vincularDispositivos() — pseudocódigo

Seção intitulada “4.4 CidadaoService.vincularDispositivos() — pseudocódigo”
função vincularDispositivos(origemId: string, destinoId: string):
// 1. Validar IDs
se !isValidUUID(origemId) ou !isValidUUID(destinoId):
lançar BadRequestException("UUID inválido")
se origemId == destinoId:
lançar BadRequestException("Origem e destino são o mesmo cidadão")
// 2. Buscar cidadãos
origem = cidadaoRepo.findById(origemId)
destino = cidadaoRepo.findById(destinoId)
se origem é null:
lançar NotFoundException("Cidadão de origem não encontrado")
se destino é null:
lançar NotFoundException("Cidadão de destino não encontrado")
// 3. Verificar se já vinculado
se origem.vinculado_a não é null:
se origem.vinculado_a == destinoId:
retornar // já vinculado, idempotente
senão:
lançar ConflictException("Cidadão de origem já vinculado a outro")
// Prevenir cadeia: destino também não pode estar vinculado
se destino.vinculado_a não é null:
destino = cidadaoRepo.findById(destino.vinculado_a)
// 4. Migrar providers e marcar vinculação (transação)
EM TRANSAÇÃO:
// Migrar auth_providers da origem para o destino
authProviderRepo.migrarCidadao(origemId, destinoId)
// UPDATE c1.auth_providers SET cidadao_id = destinoId, is_primary = false
// WHERE cidadao_id = origemId
// Marcar origem como vinculada
cidadaoRepo.update(origemId, {
vinculado_a: destinoId,
atualizado_em: now(),
})
// Garantir is_primary consistente
primarios = authProviderRepo.countByCidadaoAndPrimary(destinoId)
se primarios == 0:
// Define o provider mais recente como primário
authProviderRepo.setOldestAsPrimary(destinoId)
// 5. Publicar evento
providerDestino = authProviderRepo.findPrimaryByCidadao(destinoId)
tentar:
await eventBus.publicar({
tipo: 'cidadão.vinculado',
origem: 'C-1',
event_id: UUIDv4(),
payload: {
cidadao_id_origem: origemId,
cidadao_id_destino: destinoId,
auth_provider: providerDestino.provider,
},
})
capturar erro:
log.error("Falha ao publicar cidadão.vinculado", erro, {
origem: origemId,
destino: destinoId,
})

4.5 CidadaoService.updatePerfil() — pseudocódigo

Seção intitulada “4.5 CidadaoService.updatePerfil() — pseudocódigo”
função updatePerfil(cidadaoId: string, dto: AtualizarPerfilDto) -> Cidadao:
// 1. Validar DTO
se !dto.endereco && !dto.uc_residencia:
lançar BadRequestException("Ao menos um campo deve ser informado")
// 2. Buscar cidadão
cidadao = cidadaoRepo.findById(cidadaoId)
se cidadao é null:
lançar NotFoundException("Cidadão não encontrado")
// Redirecionar se vinculado
se cidadao.vinculado_a não é null:
cidadao = cidadaoRepo.findById(cidadao.vinculado_a)
se cidadao é null:
lançar NotFoundException("Cidadão de destino não encontrado")
// 3. Determinar campos alterados
campos = []
se dto.endereco != undefined e dto.endereco != cidadao.endereco:
campos.push('endereco')
se dto.uc_residencia != undefined e dto.uc_residencia != cidadao.uc_residencia:
campos.push('uc_residencia')
// 4. Se nada mudou, retornar sem publicar
se campos.length == 0:
retornar cidadao
// 5. Persistir
enderecoFinal = dto.endereco ?? cidadao.endereco
ucFinal = dto.uc_residencia ?? cidadao.uc_residencia
cidadaoRepo.update(cidadao.id, {
endereco: enderecoFinal,
uc_residencia: ucFinal,
atualizado_em: now(),
})
// 6. Publicar evento
tentar:
await eventBus.publicar({
tipo: 'cidadão.perfil_atualizado',
origem: 'C-1',
event_id: UUIDv4(),
correlacao_id: cidadaoId,
payload: {
cidadao_id: cidadaoId,
campos_alterados: campos,
nome: cidadao.nome,
email: cidadao.email,
avatar_url: cidadao.avatar_url,
endereco: enderecoFinal,
uc_residencia: ucFinal,
},
})
capturar erro:
log.error("Falha ao publicar cidadão.perfil_atualizado", erro, {
cidadao_id: cidadaoId,
campos,
})
retornar cidadaoRepo.findById(cidadao.id)

4.6 VinculoService.onVinculoValidado() — pseudocódigo

Seção intitulada “4.6 VinculoService.onVinculoValidado() — pseudocódigo”
função onVinculoValidado(event: EventLog):
payload = event.payload as VinculoValidadoPayload
// 1. Verificar idempotência
existente = vinculoRepo.findByEventIdOrigem(event.event_id)
se existente não é null:
log.info("vínculo.validado já processado", { event_id: event.event_id })
retornar
// 2. Validar payload mínimo
se !payload.cidadao_id ou !payload.unidade_civica_id ou !payload.status:
log.error("Payload de vínculo.validado incompleto", { event_id: event.event_id })
retornar
// 3. Upsert
vinculoRepo.upsert({
cidadao_id: payload.cidadao_id,
unidade_civica_id: payload.unidade_civica_id,
status: payload.status,
metodo_validacao: payload.metodo_validacao ?? null,
data_validade: payload.data_validade ? new Date(payload.data_validade) : null,
hash_evidencia: payload.hash_evidencia ?? null,
event_id_origem: event.event_id,
atualizado_em: now(),
})
// 4. Atualizar consumer offset
consumerOffsetRepo.upsert('vínculo.validado', event.sequence_number)

4.7 GoogleAuthService.verifyToken() — pseudocódigo

Seção intitulada “4.7 GoogleAuthService.verifyToken() — pseudocódigo”
função verifyToken(idToken: string) -> GoogleTokenPayload:
// google-auth-library: OAuth2Client.verifyIdToken()
ticket = await googleClient.verifyIdToken({
idToken: idToken,
audience: GOOGLE_CLIENT_ID, // configurado via env
})
payload = ticket.getPayload()
// Validar claims obrigatórios
se !payload.sub:
lançar UnauthorizedException("Token Google sem subject")
retornar {
sub: payload.sub,
email: payload.email ?? null,
name: payload.name ?? null,
picture: payload.picture ?? null,
}

4.8 GovbrAuthService.verifyToken() — pseudocódigo

Seção intitulada “4.8 GovbrAuthService.verifyToken() — pseudocódigo”
função verifyToken(idToken: string) -> GovbrTokenPayload:
// 1. Decodificar header para obter kid
header = JSON.parse(base64urlDecode(idToken.split('.')[0]))
kid = header.kid
// 2. Obter JWKS (com cache de 1 hora)
jwks = await getJwksWithCache()
// GET https://sso.acesso.gov.br/jwks
// Cache em memória: Map<string, jwks>, TTL 3600s
chave = jwks.keys.find(k => k.kid == kid)
se !chave:
lançar UnauthorizedException("Chave de assinatura não encontrada no JWKS")
// 3. Verificar assinatura
// Usar jose (implementação JS de JOSE) ou jsonwebtoken com chave pública
payload = await jose.jwtVerify(idToken, await jose.importJWK(chave), {
issuer: 'https://sso.acesso.gov.br',
audience: GOVBR_CLIENT_ID,
})
se !payload.sub:
lançar UnauthorizedException("Token gov.br sem subject")
retornar {
sub: payload.sub,
name: payload.name ?? null,
email: payload.email ?? null,
cpf: payload.cpf ?? null,
phone_number: payload.phone_number ?? null,
}
Caso Comportamento
Device ID não é UUID v4 HTTP 400. class-validator rejeita no pipe.
Cidadão anônimo consultado por ID, mas já vinculado GET /cidadaos/:id retorna 301 com header Location: /cidadaos/<destino>. Body contém { redirecionar_para: "<destino>" }.
Cidadão vinculado tenta se vincular novamente HTTP 409 “Cidadão já vinculado”.
Vincular origem e destino iguais HTTP 400 “Origem e destino são o mesmo cidadão”.
Vincular cadeia (A → B, e agora tentar B → C) Permitido. B não está marcado como vinculado_a. A já está resolvido. A migração de providers de B para C funciona. GET por A redireciona para B, GET por B redireciona para C.
Google sub já existe em provider, mas vinculado a outro cidadão Login retorna o cidadão existente (isNew=false). Login não gera nova identidade.
Gov.br CPF já existe em provider_data de outro cidadão gov.br Não detectado no MVP. A constraint UNIQUE é sobre (provider, provider_sub), não sobre CPF. CPFs duplicados são improváveis mas possíveis se o gov.br emitir subs diferentes para o mesmo CPF. Tratamento na Fase 3 com reconciliação.
vínculo.validado para cidadão que não existe Upsert realizado. Projeção populada. GET por aquele cidadao_id retorna 404. Se o cidadão for criado depois, o vínculo já estará lá.
vínculo.validado com status provisorio e data_validade no passado Armazenado. A interpretação de expiração é do consumer (front-end/D-7), não da C-1.
Múltiplos vínculo.validado para o mesmo par (cidadão, UC) Upsert garante que o último evento é o estado corrente. ON CONFLICT DO UPDATE.
cidadão.perfil_atualizado sem campos alterados HTTP 200. Nenhum evento publicado. Idempotente.
cidadão.perfil_atualizado com endereco idêntico ao atual Campo não incluído em campos_alterados. Nenhum evento se for o único campo.
Rollback de transação durante criação de cidadão Nenhum efeito colateral. Cliente (BFF) recebe HTTP 500 e retry. Idempotência por provider_sub garante que retry não duplique.
Concorrência: duas requisições simultâneas de criação com mesmo Google sub Primeira: INSERT bem-sucedido. Segunda: violação de UNIQUE em auth_providers. Capturada, busca registro existente, retorna sucesso.

Cadeia de vinculação (A → B → C) é permitida. No MVP da D-1a, a vinculação migrava demandas da origem para o destino — uma operação que alterava dados em tabela de outra responsabilidade (d1a.demandas_recebidas.cidadao_id). Na C-1, a vinculação apenas migra auth_providers e marca vinculado_a. Os dados migrados de outras colônias (demandas, lugares) são responsabilidade do BFF (D-1a) ou de scheduled jobs de reconciliação. A C-1 não escreve fora do schema c1.

vinculado_a é resolvido em cascata na aplicação, não no banco. PostgreSQL suporta CTE recursiva, mas a profundidade esperada de vinculação é no máximo 2 (anônimo → Google → gov.br). Resolver em aplicação é mais simples e evita queries recursivas desnecessárias. Se no futuro a cadeia crescer (improvável), o limite prático é a leitura de 3-4 linhas.

gov.br: id_token verificado, não access_token. O fluxo gov.br usa OIDC, onde o id_token contém as claims do usuário (sub, name, CPF). O access_token é opaco e usado para chamar APIs protegidas do gov.br. A C-1 não faz isso. O BFF troca o authorization code por tokens e envia apenas o id_token para a C-1. A C-1 não conhece o client_secret do gov.br. A troca do code é responsabilidade do BFF.

Cache de JWKS do gov.br por 1 hora. O endpoint JWKS do gov.br retorna as chaves públicas de verificação de assinatura. Baixar a cada requisição de login adiciona latência de rede e depende da disponibilidade do endpoint externo. Cache de 1 hora alinha com a rotação típica de chaves OIDC. Em caso de falha do JWKS após expiração do cache, a C-1 tenta baixar novamente. Se indisponível, retorna 503 (não 401 — o problema é infraestrutura, não credencial).


5. Integração com o Barramento e Outras Colônias

Seção intitulada “5. Integração com o Barramento e Outras Colônias”

A C-1 injeta EventBusService (do módulo @Global() N-0a) para publicar e consumir eventos:

@Injectable()
export class CidadaoService {
constructor(
private readonly eventBus: EventBusService,
private readonly cidadaoRepo: CidadaoRepository,
private readonly authProviderRepo: AuthProviderRepository,
) {}
async findOrCreateAnonymous(deviceId: string) {
// ... lógica de criação ...
await this.eventBus.publicar({
tipo: 'cidadão.cadastrado',
origem: 'C-1',
event_id: deviceId,
correlacao_id: deviceId,
payload: { cidadao_id: deviceId, auth_provider: 'anonymous' },
});
}
}

Para consumo, o VinculoService registra o handler no OnModuleInit:

@Injectable()
export class VinculoService implements OnModuleInit {
constructor(
private readonly eventBus: EventBusService,
private readonly vinculoRepo: VinculoRepository,
private readonly consumerOffsetRepo: ConsumerOffsetRepository,
) {}
async onModuleInit() {
await this.iniciar();
}
async iniciar() {
// Garantir o cursor no boot; o seed não sobrescreve cursor existente
const maiorSequence = await this.eventBus.obterMaiorSequence();
await this.consumerOffsetRepo.seed(['vínculo.validado'], maiorSequence);
// Replay de eventos perdidos a partir do cursor
const offsets = await this.consumerOffsetRepo.buscarTodos();
const cursor = offsets.find((o) => o.tipo_evento === 'vínculo.validado')?.last_sequence ?? 0;
const eventosPerdidos = await this.eventBus.replayDeSequence(cursor, ['vínculo.validado']);
for (const evento of eventosPerdidos) {
await this.onVinculoValidado(evento);
}
// Registrar handler para eventos futuros
this.eventBus.inscrever('vínculo.validado', 'C-1', this.onVinculoValidado.bind(this));
}
}

5.2 Relação com outras colônias — fluxo de eventos

Seção intitulada “5.2 Relação com outras colônias — fluxo de eventos”
C-1 publica:
cidadão.cadastrado → D-7 (Transparência), colônias com projeção de perfil
cidadão.perfil_atualizado → D-7 (Transparência), colônias com projeção de perfil
cidadão.vinculado → D-7 (Transparência)
C-1 consome:
vínculo.validado (D-9, Fase 2) → projeção local em c1.vinculos

A C-1 não invoca outras colônias diretamente. Apenas publica no barramento. As colônias consumidoras são notificadas sem que a C-1 saiba quais são.

5.3 Chamadas síncronas — BFF da D-1a como proxy

Seção intitulada “5.3 Chamadas síncronas — BFF da D-1a como proxy”

Na Fase 2, o BFF da D-1a chama a C-1 via HTTP para operações síncronas de cidadão. O fluxo completo de uma requisição do front-end:

Front-end → D-1a BFF → (HTTP localhost) → C-1
Event Bus → D-7 (projeção de leitura)

O BFF da D-1a não processa, transforma ou decide sobre dados de cidadão. Apenas roteia. Exemplo de proxy:

// D-1a BFF — AuthController (Fase 2)
@Post('auth/google')
async googleLogin(@Body() dto: GoogleAuthDto, @Headers('x-cidadao-id') deviceId?: string) {
// 1. Chamar C-1 para verificar/criar identidade Google
const { cidadao_id, nome, email, auth_providers, is_new } =
await this.httpService.post('http://localhost:3001/api/c1/cidadaos/google', {
id_token: dto.id_token,
});
// 2. Se veio de device anônimo, vincular
if (deviceId && deviceId !== cidadao_id) {
await this.httpService.post('http://localhost:3001/api/c1/cidadaos/vinculos', {
cidadao_origem_id: deviceId,
cidadao_destino_id: cidadao_id,
});
// 3. Migrar dados locais (demandas, lugares) do device anônimo para Google
await this.demandaRepo.transferirCidadao(deviceId, cidadao_id);
}
// 4. Gerar JWT de sessão (responsabilidade do BFF)
const jwt = this.authService.gerarToken({ sub: cidadao_id });
return { cidadao_id, nome, email, token: jwt };
}

A C-1 não sabe sobre JWTs, sessões ou migração de dados de outras colônias. Apenas gerencia identidade e providers.

A C-1 não consome projeções de leitura de outras colônias. A projeção local que a C-1 mantém (c1.vinculos) é alimentada exclusivamente por eventos do barramento (vínculo.validado).

A C-1 e a D-9 têm responsabilidades complementares sem acoplamento direto:

  • C-1: armazena a autodeclaração (endereco, uc_residencia). Projeta o resultado da validação (c1.vinculos).
  • D-9: consome a solicitação de validação (cidadão.solicitou_validação_vínculo, publicado pelo BFF), executa o método de validação e publica vínculo.validado com o resultado.

A C-1 não publica cidadão.solicitou_validação_vínculo. Esse evento é publicado pelo BFF da D-1a quando o cidadão aciona a validação pelo front-end. A decisão segue o princípio: ações iniciadas pelo usuário no front-end passam pelo BFF.

A D-7 consome os três eventos de cidadão para alimentar projeções públicas:

  • cidadão.cadastrado → registro de novo cidadão na projeção
  • cidadão.perfil_atualizado → atualização de perfil na timeline
  • cidadão.vinculado → registro de vinculação de identidades

A C-1 não conhece a D-7. Apenas publica no barramento.


Rota Limite Janela Chave Observação
POST /c1/cidadaos/anonymous 30 1 minuto IP Chamado a cada nova sessão de app
POST /c1/cidadaos/google 20 1 minuto IP Login Google é raro
POST /c1/cidadaos/govbr 20 1 minuto IP Login gov.br é raro
POST /c1/cidadaos/vinculos 10 1 minuto IP Vinculação ocorre uma vez por usuário
GET /c1/cidadaos/:id 100 1 minuto IP Chamado pelo BFF a cada requisição autenticada
PATCH /c1/cidadaos/:id 10 1 minuto IP Atualização de perfil é rara
GET /c1/cidadaos/:id/vinculos 60 1 minuto IP Carregado na tela de perfil

Os limites são mais altos que os da D-1a porque os endpoints da C-1 são internos — consumidos apenas pelo BFF, não pelo front-end. O IP é do servidor local, não do usuário. A proteção é contra loops e bugs, não contra abuso externo. Na extração para microsserviço, o rate limiting pode ser substituído por autenticação de serviço (mTLS ou API key).

Limite Valor Justificativa
endereco 500 caracteres Suficiente para endereço completo.
nome 200 caracteres Limitado pelo provider.
email 255 caracteres RFC 5321.
avatar_url 500 caracteres URL do Google.
provider_data (JSONB) 10 KB CPF + phone_number + campos futuros.
Payload REST máximo 10 KB Suficiente para tokens JWT (~2-4 KB).
Índice Query atendida
cidadaos_pkey (id) findById() — toda requisição autenticada
idx_c1_cidadaos_email (parcial) Busca por email (administrativo, Fase 3)
idx_c1_cidadaos_vinculado (parcial) “Quem está vinculado a X?”
auth_providers_pkey (id) Acesso direto
uq_c1_provider_sub (UNIQUE) findByProviderAndSub() — login Google/gov.br
idx_c1_providers_cidadao “Quais providers este cidadão tem?”
idx_c1_providers_lookup (parcial) Busca por (provider, provider_sub)
vinculos_pkey (cidadao_id, unidade_civica_id) getVinculos() — tela de perfil
idx_c1_vinculos_cidadao Listar todos os vínculos de um cidadão
idx_c1_vinculos_status “Quais vínculos válidos este cidadão tem?”

O padrão de acesso dominante é SELECT por id:

  • findById(): 1 query por requisição autenticada que o BFF proxy. Cache em memória reduz carga.
  • findByProviderAndSub(): apenas no fluxo de login Google/gov.br. Esperado < 10 queries/minuto por provider na Fase 2.
  • getVinculos(): carregado na tela de perfil. Esperado < 1 query por sessão de usuário.
  • INSERT em c1.cidadaos + c1.auth_providers: apenas no primeiro login de cada provider. Transação de 2 INSERTs.

Cache LRU em memória no CidadaoService para findById():

// Estrutura: Map<string, { data: Cidadao, ts: number }>
// TTL: 300 segundos (5 minutos)
// Capacidade máxima: 10.000 entradas (LRU eviction)
// Invalidado em: updatePerfil(), vincularDispositivos()

A justificativa para o TTL de 5 minutos: com a C-1 em serviço independente, o custo de uma query ao banco é maior (latência de rede). Um TTL maior reduz queries. Atualizações de perfil são raras, e a janela de até 5 minutos com dados desatualizados para nome e email é aceitável. O MVP da D-1a não tem cache de cidadão.

Cache NÃO é aplicado a auth_providers — a consulta de login precisa ser sempre fresca. Cache NÃO é aplicado a vinculos — os dados mudam por evento externo (vínculo.validado), não por ação do cidadão.

Cenário Cidadãos cadastrados Providers Logins/dia GET /:id/dia Tamanho estimado do banco (ano)
1 município (~100k hab) ~5.000 ~7.000 ~500 ~50.000 < 500 MB
10 municípios ~50.000 ~70.000 ~5.000 ~500.000 < 5 GB
Regional (~1M hab) ~100.000 ~150.000 ~10.000 ~1.000.000 < 10 GB

O crescimento é linear com a base de cidadãos. A tabela vinculos cresce com cidadãos × UCs (um cidadão pode ter vínculo com múltiplas UCs), mas na prática a maioria dos cidadãos tem vínculo com 1-3 UCs.


Teste unitário do CidadaoService:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
CidadaoService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: CidadaoRepository, useValue: mockCidadaoRepo },
{ provide: AuthProviderRepository, useValue: mockAuthProviderRepo },
],
}).compile();
service = module.get(CidadaoService);
mockCidadaoRepo.findById.mockResolvedValue(null);
mockCidadaoRepo.insert.mockImplementation((data) =>
Promise.resolve({ ...data, criado_em: new Date().toISOString() })
);
mockAuthProviderRepo.findByProviderAndSub.mockResolvedValue(null);
mockAuthProviderRepo.insert.mockImplementation((data) =>
Promise.resolve({ ...data, criado_em: new Date().toISOString() })
);
mockEventBus.publicar.mockResolvedValue({
sequence_number: 1,
event_id: 'test-uuid',
tipo: 'cidadão.cadastrado',
});
});

Teste unitário do VinculoService:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
VinculoService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: VinculoRepository, useValue: mockVinculoRepo },
{ provide: ConsumerOffsetRepository, useValue: mockOffsetRepo },
],
}).compile();
service = module.get(VinculoService);
});

Teste e2e dos controllers (supertest):

const app = await Test.createTestingModule({
imports: [C1Module],
}).compile();
const httpServer = app.createNestApplication();
await httpServer.init();
// Testar POST /api/c1/cidadaos/anonymous
return request(httpServer.getHttpServer())
.post('/api/c1/cidadaos/anonymous')
.send({ device_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' })
.expect(201)
.expect((res) => {
expect(res.body.cidadao_id).toBeDefined();
expect(res.body.auth_providers).toContain('anonymous');
});

Happy path:

# Cenário Verificação
T1 POST /anonymous com device ID novo HTTP 201. c1.cidadaos tem 1 linha. c1.auth_providers tem 1 linha com provider=‘anonymous’. Evento cidadão.cadastrado publicado.
T2 POST /anonymous com device ID existente HTTP 200. is_new: false. Nenhum evento publicado. count(*) inalterado.
T3 POST /google com token válido, usuário novo HTTP 201. Cidadão criado com nome, email, avatar. auth_providers com provider=‘google’ e provider_sub. Evento publicado.
T4 POST /google com token válido, usuário existente HTTP 200. Nome e email atualizados se mudaram no Google. Nenhum evento publicado.
T5 POST /govbr com token válido, usuário novo HTTP 201. provider_data contém CPF. Evento publicado.
T6 POST /vinculos com IDs válidos HTTP 200. auth_providers migrados. Origem com vinculado_a preenchido. Evento cidadão.vinculado publicado.
T7 PATCH /:id com endereço novo HTTP 200. campos_alterados contém ‘endereco’. Evento cidadão.perfil_atualizado publicado.
T8 GET /:id de cidadão existente HTTP 200. Body contém todos os campos + auth_providers.
T9 GET /:id/vinculos HTTP 200. Array de vínculos por UC.

Falhas e bordas:

# Cenário Verificação
T10 POST /anonymous com device ID inválido (não-UUID) HTTP 400. class-validator.
T11 POST /google com token expirado HTTP 401. GoogleAuthService propaga erro.
T12 POST /govbr com token de audience diferente HTTP 401. Verificação de aud falha.
T13 POST /govbr com JWKS indisponível HTTP 503. Erro de infraestrutura, não de credencial.
T14 POST /vinculos com origem inexistente HTTP 404 “Cidadão de origem não encontrado”.
T15 POST /vinculos com destino inexistente HTTP 404 “Cidadão de destino não encontrado”.
T16 POST /vinculos com origem já vinculada HTTP 409 “Cidadão já vinculado”.
T17 PATCH /:id sem campos HTTP 400 “Ao menos um campo deve ser informado”.
T18 PATCH /:id com endereço idêntico HTTP 200. Nenhum evento publicado.
T19 GET /:id de cidadão vinculado HTTP 301. Header Location. Body contém redirecionar_para.
T20 GET /:id inexistente HTTP 404.
T21 vínculo.validado com payload incompleto Log.error. Evento descartado. Offset NÃO atualizado (será reentregue).
T22 vínculo.validado reentregue (mesmo event_id) Detectado por event_id_origem. Log.info, retorna.
T23 vínculo.validado para cidadão inexistente + cidadão criado depois Upsert realizado. GET /:id/vinculos retorna o vínculo após criação do cidadão.
T24 eventBus.publicar() falha em findOrCreateAnonymous Cidadão persistido. Evento não publicado. Log.error. HTTP 201. Cidadão funcional.

Teste de integração (com PostgreSQL de teste):

# Cenário Verificação
T25 Criação anônima + Google + vinculação c1.cidadaos: 2 linhas (anon, google). Anon com vinculado_a = google.id. c1.auth_providers: 2 linhas, ambas com cidadao_id = google.id. core.event_log: 2 eventos (cidadão.cadastrado ×2) + 1 (cidadão.vinculado).
T26 Transação de criação de cidadão Google: INSERT em cidadaos ok, INSERT em auth_providers falha Rollback. count(*) em c1.cidadaos inalterado. Nenhum evento.
T27 vínculo.validado upsert + re-entrega Primeira entrega: INSERT. Segunda: ON CONFLICT DO UPDATE. count(*) = 1. event_id_origem atualizado.
T28 Concorrência: 2 POSTs /google simultâneos com mesmo sub Um INSERT em auth_providers bem-sucedido. Outro viola UNIQUE, capturado, retorna cidadão existente. count(*) = 1.
-- Cidadão anônimo
INSERT INTO c1.cidadaos (id)
VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
INSERT INTO c1.auth_providers (cidadao_id, provider, is_primary)
VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'anonymous', true);
-- Cidadão Google
INSERT INTO c1.cidadaos (id, nome, email, avatar_url, endereco, uc_residencia)
VALUES (
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'Maria Silva',
'maria@gmail.com',
'https://lh3.googleusercontent.com/a/avatar-maria',
'Rua das Flores, 123',
'c3d4e5f6-a7b8-9012-cdef-123456789012'
);
INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary)
VALUES (
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'google',
'google-sub-maria-98765',
true
);
-- Cidadão gov.br
INSERT INTO c1.cidadaos (id, nome, email)
VALUES (
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'João Souza',
'joao@email.com'
);
INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, provider_data, is_primary)
VALUES (
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'govbr',
'govbr-sub-joao-54321',
'{"cpf": "***.123.456-**", "phone_number": "+5511999999999"}',
true
);
-- Vínculo validado
INSERT INTO c1.vinculos (cidadao_id, unidade_civica_id, status, metodo_validacao,
data_validade, hash_evidencia, event_id_origem)
VALUES (
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'valido',
'documental',
'2028-01-15T00:00:00Z',
'abc123def456',
'd4e5f6a7-b8c9-0123-defa-123456789abc'
);
-- Consumer offset
INSERT INTO c1.consumer_offset (tipo_evento, last_sequence)
VALUES ('vínculo.validado', 0);

8.1 O que é Fase 1 (D-1a) e o que é Fase 2 (C-1)

Seção intitulada “8.1 O que é Fase 1 (D-1a) e o que é Fase 2 (C-1)”
Funcionalidade Fase 1 (MVP — D-1a) Fase 2 (C-1)
Residência dos dados Schema d1a, tabela d1a.cidadaos Schema c1, tabelas c1.cidadaos, c1.auth_providers, c1.vinculos
Providers de autenticação anonymous, google anonymous, google, govbr
Mapeamento de providers Colunas auth_provider, google_sub em d1a.cidadaos Tabela c1.auth_providers (N providers por cidadão)
Interface de acesso Interna ao BFF (in-process) REST API interna (HTTP localhost → microsserviço)
Vinculação de identidades Migração de demandas no BFF + evento Migração de providers na C-1 + evento. Dados externos migrados pelo BFF
Projeção de vínculo Não existe (D-9 é Fase 2) c1.vinculos, alimentada por vínculo.validado
JWT de sessão Gerado pelo BFF Gerado pelo BFF (C-1 não gerencia sessões)
Cache de cidadão Sem cache, consulta direta ao banco In-memory LRU, TTL 300s, capacidade 10k
Endpoints REST GET/PATCH /api/cidadaos/me no BFF GET/PATCH/POST /api/c1/cidadaos/* na C-1
Resolução de UC de residência Nenhuma — armazena o UUID declarado Nenhuma — armazena o UUID declarado (idem)

A migração da Fase 1 para a Fase 2 segue esta sequência:

Passo 1 — Criar schema c1 e tabelas. Rodar migrations V001-V004. O schema d1a continua operando normalmente. A C-1 existe como módulo, mas ainda não recebe tráfego.

Passo 2 — Migrar dados existentes. Script de migração única:

-- Migrar cidadãos
INSERT INTO c1.cidadaos (id, nome, email, avatar_url, endereco, uc_residencia, criado_em, atualizado_em)
SELECT id, nome, email, avatar_url, endereco, uc_residencia, criado_em, atualizado_em
FROM d1a.cidadaos;
-- Migrar providers
INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary, criado_em)
SELECT
id AS cidadao_id,
auth_provider AS provider,
CASE WHEN auth_provider = 'google' THEN google_sub ELSE NULL END AS provider_sub,
true AS is_primary,
criado_em
FROM d1a.cidadaos;

O script é idempotente — pode ser reexecutado sem duplicar (PK + UNIQUE constraints previnem).

Passo 3 — Ativar C-1 com tráfego espelhado. O BFF passa a chamar a C-1 para novas operações (findOrCreate, updatePerfil, vinculação) e ainda consulta a tabela d1a.cidadaos para cidadãos legados como fallback. Operações de leitura: tenta C-1 primeiro, fallback para d1a.cidadaos. Operações de escrita: sempre na C-1 (com upsert para tratar cidadãos legados que ainda não foram migrados).

Passo 4 — Desligar fallback. Após período de validação (todas as operações de escrita passando pela C-1 sem erro por N dias), remover fallback de leitura. Todas as operações de cidadão passam exclusivamente pela C-1.

Passo 5 — Remover tabela legada. DROP TABLE d1a.cidadaos (mantida como backup por período de segurança). Remover dependências residuais no BFF.

Simplificação Justificativa Quando remover
Cache LRU em memória (sem Redis) Monolito de processo único. Volume de leitura moderado na Fase 2. Migrar para Redis quando a C-1 for microsserviço com múltiplas instâncias.
vinculado_a resolvido em aplicação (não CTE recursiva) Profundidade máxima esperada: 2 (anônimo → Google → gov.br). Se a cadeia crescer além de 3 níveis.
Sem criptografia em repouso para provider_data.CPF Volume baixo. Schema preparado para adição futura (JSONB). Adicionar pgcrypto quando houver auditoria de segurança formal.
Rate limiting por IP Endpoints internos, consumidos apenas pelo BFF. Substituir por autenticação de serviço (mTLS/API key) na extração para microsserviço.
JWKS do gov.br em cache de memória Chaves rotacionam com baixa frequência. TTL de 1 hora cobre o ciclo. Migrar para Redis se houver múltiplas instâncias da C-1.
  • Criptografia em repouso de dados sensíveis (provider_data.CPF com pgcrypto)
  • Índice GIN em auth_providers.provider_data para consulta por CPF
  • Reconciliação de CPFs duplicados entre providers gov.br
  • Endpoint administrativo de busca de cidadão por CPF (restrito a operadores autorizados)
  • Deleção de cidadão por requisição LGPD (remoção de chave criptográfica + anonimização)
  • Cache Redis compartilhado para findById() (múltiplas instâncias da C-1)
  • Métricas Prometheus: c1_cidadaos_created_total, c1_logins_total{provider}, c1_vinculos_validos_total
  • Rate limiting por serviço (mTLS) em vez de IP

8.5 Verificação de conflitos com outras colônias

Seção intitulada “8.5 Verificação de conflitos com outras colônias”

Conflito potencial: cidadão.cadastrado publicado pela C-1 com origem: 'C-1' vs. Fase 1 com origem: 'D-1a'. Consumidores que registram offset por colônia (D-7, projeções de perfil) precisam consumir de ambas as origens durante a transição. Após a migração completa, apenas origem: 'C-1' é emitida. Solução: handlers registram para o tipo de evento, não para a origem. O campo origem é metadado de auditoria, não chave de roteamento.

Conflito potencial: cidadão.vinculado migra apenas auth_providers, mas outras colônias esperam migração de dados. No MVP (D-1a), a vinculação migrava demandas (d1a.demandas_recebidas.cidadao_id) dentro do BFF. Na Fase 2, a C-1 migra apenas seus dados (auth_providers). O BFF da D-1a continua responsável por migrar dados de outras colônias que referenciam cidadão_id (demandas, lugares). O evento cidadão.vinculado notifica as demais colônias para que cada uma faça sua própria migração. Sem conflito.

Conflito potencial: vínculo.validado consumido pela C-1 mas a D-9 é Fase 2. A C-1 implementa o handler e a tabela c1.vinculos desde a Fase 2. Se a D-9 ainda não estiver implementada, o handler existe mas nunca é invocado. A tabela fica vazia, o endpoint GET /:id/vinculos retorna array vazio. Sem conflito.

Conflito potencial: gov.br armazena CPF, mas LGPD exige proteção especial. O CPF é armazenado em auth_providers.provider_data (JSONB), não em coluna dedicada. Não é retornado nos endpoints REST públicos (GET /:id, GET /:id/vinculos). É acessível apenas internamente pelo GovbrAuthService no momento do login e por endpoints administrativos (Fase 3). O campo provider_data é excluído da serialização padrão. Sem conflito com LGPD no escopo da Fase 2.


  • Ficha técnica da colônia: Apêndice B - Colônias.md, seção “C-1 — Cadastro de Cidadãos”
  • Implementação Fase 1 (referência): D-1a - BFF.md, seções 2.2 (Tabela d1a.cidadaos), 3.3 a 3.5 (eventos de cidadão) e 4.3 a 4.5 (pseudocódigo do CidadaoService)
  • Schemas de eventos: N-0b - Registry.md, seções 3.4.28 (cidadão.cadastrado), 3.4.29 (cidadão.perfil_atualizado) e 3.4.30 (cidadão.vinculado); o schema de vínculo.validado entra no catálogo na Fase 2, conforme a seção 8.3 do N-0b
  • Barramento de eventos: N-0a - Event Bus.md
  • D-9 (Validação de Vínculo): Apêndice B - Colônias.md, seção “D-9 — Validação de Vínculo”
  • Colônia consumidora dos eventos de cidadão: D-7 - Transparência.md
  • Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
  • Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
  • Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
  • Contexto de gestão: contexto_IA.md, seção 7 (Gestão)
  • Contexto de infraestrutura: contexto_IA.md, seção 10 (Infraestrutura cívica digital)
  • Contexto do Formigueiro: contexto_IA.md, seção 22 (Arquitetura técnica — O Formigueiro)

Documento de especificação técnica de implementação. Fase 2 — Microsserviço independente.