Pular para o conteúdo

Invariantes Críticos

Invariantes são regras de negócio que nunca podem ser violadas, independentemente do estado do sistema. No canna-oss, cada invariante é enforced em múltiplas camadas para garantia máxima.


Estes invariantes envolvem mais de um bounded context e são enforced na camada de aplicação via orquestração de use cases, não dentro de um único aggregate.

Σ(dispensations_g this month for member M) + new_g ≤ M.quota_g_month

Verificado no use case RecordDispensation antes de criar o aggregate Dispensation. Falha gera QuotaExceededAttempt event sem criar dispensação.

2. Liberação de Lote Requer Aprovação de Lab (Processing × Inventory)

Seção intitulada “2. Liberação de Lote Requer Aprovação de Lab (Processing × Inventory)”

InventoryLot só transita de QUARANTINE para AVAILABLE após LabSampleApproved com approver.role = RESPONSAVEL_TECNICO. Verificado no event handler de LabSampleApproved.

PostgreSQL RULE bloqueia UPDATE e DELETE na tabela event_log. Nenhuma linha pode ser alterada após inserção — garantia no nível do banco de dados, independente da aplicação.

CREATE RULE no_update_event_log AS ON UPDATE TO event_log DO INSTEAD NOTHING;
CREATE RULE no_delete_event_log AS ON DELETE TO event_log DO INSTEAD NOTHING;

4. Segregação RBAC (Identity × Dispensation × Processing × Cultivation)

Seção intitulada “4. Segregação RBAC (Identity × Dispensation × Processing × Cultivation)”
dispenser (DISPENSADOR) ≠ COA approver (RESPONSAVEL_TECNICO) ≠ cultivador (CULTIVADOR)

Exigência RDC 1.014. Verificado em middleware antes de cada use case. Um único usuário não pode acumular os três roles.

5. COA Hash Imutável Após Aprovação (Processing)

Seção intitulada “5. COA Hash Imutável Após Aprovação (Processing)”

LabSample.coa_file_hash nunca pode ser alterado após LabSampleApproved. PostgreSQL constraint + guard no aggregate impedem qualquer modificação.

CPFHash = SHA-256(cpf + site_salt) é único dentro do tenant. site_salt é diferente por instância, garantindo que o mesmo CPF não possa ser correlacionado entre associações distintas. Unique constraint no banco.

ULID de planta é permanente e nunca reutilizado, mesmo após destruição. Constraint UNIQUE na tabela plants + guard no aggregate ao registrar nova planta.


INV-M1 — Consentimento Antes de Dispensação Membro em estado PENDING_CONSENT ou SUSPENDED não pode ter dispensações registradas. Guard no use case RecordDispensation verifica member.status === 'ACTIVE' e member.consent_version === current_consent_version.

INV-M2 — Validade da Prescrição Prescrição médica expirada (expired_at < now()) transiciona automaticamente o membro para SUSPENDED via job agendado. Dispensação bloqueada enquanto em SUSPENDED.

INV-M3 — Guard do Membro Anonimizado ANONYMIZED é estado terminal. Qualquer operação sobre membro anonimizado retorna erro de domínio MemberAnonymizedError. Guard no aggregate antes de qualquer método mutante.

INV-M4 — Quota Enforced no Mês Corrente Quota é calculada com janela de calendário (início do mês UTC → fim do mês UTC). Sem carry-over entre meses.


INV-C1 — Progressão de Estágio Forward-Only A state machine de Plant só avança para frente: GERMINATING → SEEDLING → VEGETATIVE → FLOWERING → HARVESTED. Qualquer tentativa de regressão retorna InvalidStageTransitionError.

INV-C2 — Destruição Requer Testemunha PlantDestroyed só é emitido se witness_user_id for fornecido e o usuário existir com role CULTIVADOR ou RESPONSAVEL_TECNICO. Sem testemunha = comando rejeitado.

INV-C3 — Fair Value Obrigatório no Harvest HarvestRecorded é rejeitado se fair_value_brl não for fornecido ou for ≤ 0. Compliance com CPC 29 — ativo biológico deve ser valorado a valor justo na colheita.


INV-P1 — Apenas RESPONSAVEL_TECNICO Aprova Lab Use case ApproveLabSample verifica approver.role === 'RESPONSAVEL_TECNICO' antes de qualquer operação. Role insuficiente retorna InsufficientRoleError.

INV-P2 — COA Hash Imutável Após LabSampleApproved, o campo coa_file_hash é marcado como immutable: true no aggregate. Qualquer tentativa de atualização retorna ImmutableFieldError. PostgreSQL column-level trigger como segunda linha de defesa.

INV-P3 — Lab Rejeitado Bloqueia Lote LabSampleRejected emite evento consumido por Inventory, que mantém o InventoryLot em QUARANTINE. Novo ciclo de amostragem deve ser iniciado explicitamente.


INV-D1 — Imutabilidade Após Criação Dispensation não possui métodos mutantes após DispensationRecorded. Sem cancel, sem update. Correções são feitas por nova dispensação compensatória com quantidade negativa (estorno), rastreada no audit log.

INV-D2 — Consumo Atômico de Quota e Estoque

RecordDispensation só é aceito se, no momento do decide(), o membro tem quota suficiente E o lote tem quantidade suficiente. O mesmo append no event store registra os três eventos:

  • DispensationRecorded
  • MemberQuotaConsumed
  • LotQuantityDeducted

Read models de quota e estoque são projeções desses eventos. Nenhum job assíncrono pode alterar estado regulatório crítico — side effects externos (SNGPC XML, PDF, email) vão para BullMQ e sua falha não invalida a dispensação. Cf. ADR-001.

Concorrência entre dispensações no mesmo lote é protegida por optimistic concurrency no stream do lote: dois RecordDispensation paralelos no mesmo inventory_lot_ref não podem ambos passar — o segundo append falha por versão divergente e é re-validado contra o estado atualizado.


Cada invariante é verificado em múltiplas camadas independentes. Uma falha em qualquer camada impede a violação.

Invariantes verificados dentro dos métodos dos aggregates antes de emitir qualquer domain event.

// Exemplo: guard de quota no aggregate Member
recordDispensation(quantityG: number): Result<void, QuotaExceededError> {
const used = this.monthlyUsageG;
if (used + quantityG > this.quotaGMonth) {
return err(new QuotaExceededError({ used, requested: quantityG, quota: this.quotaGMonth }));
}
// ... prossegue
}

Use cases verificam pré-condições cross-context antes de invocar aggregates. Inclui verificações de role, estado de entidades em outros contextos e regras de negócio que envolvem múltiplos aggregates.

// Exemplo: use case RecordDispensation
async execute(cmd: RecordDispensationCommand): Promise<Result<void, DomainError>> {
const member = await this.memberRepo.findById(cmd.memberRef);
if (member.status !== 'ACTIVE') return err(new MemberNotActiveError());
const lot = await this.lotRepo.findById(cmd.lotRef);
if (lot.state !== 'AVAILABLE') return err(new LotNotAvailableError());
// delega ao aggregate para verificação de quota
return member.recordDispensation(cmd.quantityG);
}

Constraints e RULE como última linha de defesa, independente da aplicação.

MecanismoInvariante Protegido
UNIQUE constraint em cpf_hash, tenant_idCPF único por tenant
UNIQUE constraint em plant_idULID de planta nunca reutilizado
CHECK (quantity_g > 0) em dispensationsQuantidade positiva
CHECK (amount_brl ~ '^\d+\.\d{2}$')Formato Decimal(15,2) em Finance
RULE no_update/no_delete em event_logAudit log imutável
Column-level trigger em lab_samples.coa_file_hashCOA hash imutável pós-aprovação

Role verificado em middleware HTTP antes da execução de qualquer use case. Sem bypass possível via API direta.

// Middleware de role enforcement
function requireRole(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'InsufficientRole' });
}
next();
};
}
// Rota protegida
router.post('/lab-samples/:id/approve',
requireRole('RESPONSAVEL_TECNICO'),
approveLabSampleController
);