Pular para o conteúdo principal
Versão: main (dev) 🚧

ADR-003 — AuditTimestamp value object: UTC instant + IANA timezone, per-timestamp

Data: 2026-05-01 Status: Aceito Contexto: DevPack 10.2.0, Bundle A

Contexto

Até a versão 2.1.1, IAuditable e ISoftDeletable usavam DateTimeOffset para todos os campos de auditoria (Created, LastModified, DeletedOnUtc). DateTimeOffset armazena o offset numérico no momento da gravação, não a timezone que o produziu. Consequências práticas:

  1. Não é possível recalcular horário local futuro com precisão. Se um registro foi criado em São Paulo às 12:00 BRT (-03:00), e o Brasil reinstaurar DST no ano seguinte, é impossível saber se "12:00" daquele registro era horário padrão ou de verão sem informação adicional sobre a timezone original.
  2. Não dá pra responder "qual foi o horário local em São Paulo?" — o offset -03:00 poderia ser de São Paulo, Buenos Aires, qualquer fuso UTC−3.
  3. Mistura dois conceitos: o instante absoluto e a apresentação local.

Ao mesmo tempo, projetos enterprise (referência: WorkGuardian) precisam preservar três sinais de auditoria que o DateTimeOffset original não capturava bem:

  • O instante do evento (UTC inequívoco — para queries e ordenação).
  • A timezone do usuário que executou o evento (para reapresentar localmente e auditar contextos como "criado às 9h da manhã do horário do criador").
  • O autor do evento (CreatedBy, ModifiedBy, DeletedBy).

Criação, atualização e deleção podem ocorrer em fusos diferentes: registro criado em São Paulo, atualizado por agente em Lisboa, deletado por outro em Tóquio. Um único LocalTimeZone por entidade perde essa informação.

Decisão

Introduzir um value object AuditTimestamp que encapsula o par (instante UTC, timezone IANA), e usar uma instância por evento de auditoria:

[Owned]
public sealed record AuditTimestamp(DateTime UtcDateTime, string? LocalTimeZone)
{
public static AuditTimestamp Now(string? localTimeZone) => new(DateTime.UtcNow, localTimeZone);
public DateTimeOffset ToLocal() { /* IANA conversion, fallback UTC */ }
}

Contratos resultantes:

public interface IAuditable : IInactivable
{
AuditTimestamp CreatedAt { get; }
string? CreatedBy { get; }
AuditTimestamp? UpdatedAt { get; }
string? ModifiedBy { get; } // renomeado de LastModifiedBy

void SetCreatedAudit(string? createdBy, AuditTimestamp createdAt);
void SetUpdatedAudit(string? modifiedBy, AuditTimestamp updatedAt);
}

public interface ISoftDeletable
{
bool IsDeleted { get; }
AuditTimestamp? DeletedAt { get; }
string? DeletedBy { get; }
void SoftDelete(string? deletedBy, AuditTimestamp deletedAt);
}

null em LocalTimeZone significa UTC — interpretação para seeds, migrations, jobs sem usuário.

Mapeamento EF Core

AuditTimestamp é anotado com [Owned]. EF auto-mapeia como propriedade owned e persiste cada AuditTimestamp em duas colunas:

CreatedAt_UtcDateTime datetime2 NOT NULL
CreatedAt_LocalTimeZone nvarchar(64) NULL
UpdatedAt_UtcDateTime datetime2 NULL
UpdatedAt_LocalTimeZone nvarchar(64) NULL
DeletedAt_UtcDateTime datetime2 NULL
DeletedAt_LocalTimeZone nvarchar(64) NULL

Sem configuração manual no OnModelCreating para o caso comum.

Reconstrução do horário local

DateTimeOffset local = entity.CreatedAt.ToLocal(); // IANA aplicado, fallback UTC
DateTime utc = entity.CreatedAt.UtcDateTime; // queryable em LINQ-to-EF

AuditTimestamp.ToLocal() usa TimeZoneInfo.TryFindSystemTimeZoneById (.NET 8+) e degrada silenciosamente para UTC quando o IANA id é desconhecido — nunca lança.

Alternativas consideradas

Um único LocalTimeZone por entidade (proposta inicial do prompt)

public AuditTimestamp CreatedAt { get; }
public AuditTimestamp UpdatedAt { get; }
public string? LocalTimeZone { get; } // único, capturado na criação

Descartada. Cenário canônico: registro criado em São Paulo, atualizado por agente em Lisboa. O update aconteceu às 15h local de Lisboa, não às 11h local de São Paulo. Um único LocalTimeZone força reescrever a timezone (perdendo a informação da criação) ou manter a antiga (mentindo sobre o contexto do update). Per-timestamp resolve sem ambiguidade.

Remover atribuição de usuário (CreatedBy/ModifiedBy/DeletedBy)

Aplicada inicialmente, depois revertida via feedback do tech lead. A motivação para removê-las (audit log dedicado seria responsável) foi descartada porque:

  • Audit log dedicado é decisão por projeto, não convenção do DevPack.
  • IAuditable historicamente carregava atribuição — remover quebra muito código.
  • A pergunta "quem criou esse registro?" via leitura direta da linha tem valor ergonômico significativo. Cruzar com tabela de audit log a cada query é fricção desnecessária.

A atribuição volta com LastModifiedBy renomeado para ModifiedBy (consistência com UpdatedAt).

HasConversion<string> em coluna única

builder.Property(e => e.CreatedAt)
.HasConversion(v => $"{v.UtcDateTime:O}|{v.LocalTimeZone}", s => Parse(s));

Descartada como default. Compacta (1 coluna), mas perde queryability dos campos individuais (WHERE r.CreatedAt.UtcDateTime > X deixa de funcionar). Para audit, queries por timestamp são frequentes — [Owned] (2 colunas) é melhor trade-off.

HasConversion permanece disponível como override opcional para projetos que prefiram coluna única, documentado como alternativa no Domain/README.md.

ComplexProperty (EF Core 8+) em vez de [Owned]

ComplexProperty é a forma moderna de mapear value objects sem identidade EF. Funcionalmente equivalente ao [Owned] para o caso aqui — duas colunas, sem tracking. A diferença é semântica: [Owned] cria FK oculto; ComplexProperty não.

Escolhemos [Owned] porque é maduro desde EF 2.x, suportado por todos os providers (InMemory inclusive — usado nos testes), e a diferença não vaza para a API pública. Migrar para ComplexProperty em uma release futura é mecânico (remover atributo, configurar via fluent API).

Manter DateTimeOffset + adicionar coluna de timezone

Descartada. Duplica informação (offset já está no DateTimeOffset) e não elimina a ambiguidade da timezone original.

Consequências

  • Breaking change em IAuditable/ISoftDeletable/AuditedEntity/ AuditedSoftDeletableEntity/BaseIdentityUser/BaseIdentityRole. Renames: CreatedCreatedAt, LastModifiedUpdatedAt, DeletedOnUtcDeletedAt, LastModifiedByModifiedBy. Tipos: DateTimeOffsetAuditTimestamp.
  • Migration SQL necessária nos consumidores: cada coluna datetimeoffset vira duas colunas ({Property}_UtcDateTime datetime2 + {Property}_LocalTimeZone nvarchar(64)). Schema migration é mecânica mas não automática.
  • AuditableEntityInterceptor agora recebe IServiceProvider e resolve ICurrentUser por escopo via CreateScope() — evita captura scoped-em-singleton. SoftDeleteInterceptor recebe (IServiceProvider, TimeProvider) pelo mesmo motivo.
  • Cobertura de testes: novo teste-chave valida que update por usuário em Lisboa não muda o CreatedAt.LocalTimeZone original (São Paulo) — o cenário-mãe da redesign.
  • Apresentação local barata: entity.CreatedAt.ToLocal() reconstrói o DateTimeOffset na timezone original, ou degrada para UTC se desconhecida.

Referências

  • ADR-001 — No framework wrappers (princípio que orientou manter AuditTimestamp como value object puro, sem dependência EF além do attribute [Owned]).
  • ADR-002 — Rename Mediator → Cqrs (precedente de breaking change com namespace rename + guia de migração detalhado no CHANGELOG).
  • docs/devpack-10.2.0-bundle-a-relatorio.md — relatório operacional do Bundle A.
  • Domain/AuditTimestamp.cs
  • Domain/IAuditable.cs
  • Domain/ISoftDeletable.cs