M1: audit log (Governance) + edit-distance metric

SharedKernel:
- IAuditLog/AuditEvent — append-only audit contract any module writes through.
- EditDistance (Levenshtein + normalized) — the north-star metric, available from day
  one; consumed at edit-and-approve in M5.

Governance module (references SharedKernel only):
- AuditEntry entity; internal GovernanceDbContext (schema "governance") +
  InitialGovernance migration; AuditLog implements IAuditLog.
- GET /api/governance/audit — owner-only (ViewAuditLog), returns recent entries.

Wiring (via the SharedKernel IAuditLog interface — no module references Governance):
- OrgBoard records team.created, task.created, task.moved, task.assigned.
- Identity records invitation.created, member.joined.

Verified: build green; ArchitectureTests 8/8 (Governance references only SharedKernel;
audit flows through the shared interface); IntegrationTests 20/20 — board flow now
asserts task.created/task.moved appear in the audit log, plus EditDistance unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 12:18:30 +03:30
parent e1911f58b1
commit fa9046a03e
16 changed files with 499 additions and 21 deletions
@@ -0,0 +1,25 @@
using TeamUp.Modules.Governance.Domain;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Auditing;
namespace TeamUp.Modules.Governance.Auditing;
/// <summary>
/// Writes audit events to the governance store. Uses its own DbContext/transaction (best-effort,
/// decoupled from the acting module's unit of work) — sufficient for M1.
/// </summary>
internal sealed class AuditLog(GovernanceDbContext db, TimeProvider clock) : IAuditLog
{
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
db.AuditEntries.Add(new AuditEntry(
auditEvent.Action,
auditEvent.EntityType,
auditEvent.EntityId,
auditEvent.ActorMemberId,
auditEvent.Details,
clock.GetUtcNow()));
await db.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,34 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.Governance.Domain;
/// <summary>An immutable audit record. Append-only — never updated or deleted.</summary>
internal sealed class AuditEntry : Entity
{
public string Action { get; private set; } = null!;
public string EntityType { get; private set; } = null!;
public Guid EntityId { get; private set; }
public Guid? ActorMemberId { get; private set; }
public string? Details { get; private set; }
public DateTimeOffset OccurredAtUtc { get; private set; }
private AuditEntry()
{
}
public AuditEntry(
string action,
string entityType,
Guid entityId,
Guid? actorMemberId,
string? details,
DateTimeOffset occurredAtUtc)
{
Action = action;
EntityType = entityType;
EntityId = entityId;
ActorMemberId = actorMemberId;
Details = details;
OccurredAtUtc = occurredAtUtc;
}
}
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Governance.Endpoints;
internal sealed record AuditEntryResponse(
Guid Id,
string Action,
string EntityType,
Guid EntityId,
Guid? ActorMemberId,
string? Details,
DateTimeOffset OccurredAtUtc);
internal static class GovernanceEndpoints
{
public static void Map(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/governance").WithTags("Governance");
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
group.MapGet("/audit", GetAudit).RequireAuthorization();
}
private static async Task<IResult> GetAudit(
Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
{
// Owner-only. (M1 audit entries are not yet org-scoped — fine for single-org dogfood.)
if (!permissions.Has(Capability.ViewAuditLog, ScopeRef.Org(organizationId)))
{
return Results.Forbid();
}
var limit = Math.Clamp(take ?? 100, 1, 500);
var entries = await db.AuditEntries
.OrderByDescending(a => a.OccurredAtUtc)
.Take(limit)
.Select(a => new AuditEntryResponse(
a.Id, a.Action, a.EntityType, a.EntityId, a.ActorMemberId, a.Details, a.OccurredAtUtc))
.ToListAsync(ct);
return Results.Ok(entries);
}
}
@@ -1,27 +1,32 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TeamUp.Modules.Governance.Auditing;
using TeamUp.Modules.Governance.Endpoints;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Governance;
/// <summary>Autonomy dial, the action gate, the review inbox, the audit log (M5).</summary>
/// <summary>Autonomy dial, the action gate, the review inbox, the audit log (M5). M1 ships the audit log.</summary>
public sealed class GovernanceModule : IModule
{
public string Name => "governance";
public void Register(IServiceCollection services, IConfiguration configuration)
{
// Skeleton: no services yet. M5 introduces the action gate, ReviewItem context,
// edit-distance capture, and the immutable audit log here.
var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.AddDbContext<GovernanceDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>());
services.AddScoped<IAuditLog, AuditLog>();
services.TryAddSingleton(TimeProvider.System);
}
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
endpoints.MapGroup($"/api/{Name}")
.WithTags("Governance")
.MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name)));
}
public void MapEndpoints(IEndpointRouteBuilder endpoints) => GovernanceEndpoints.Map(endpoints);
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.Governance.Domain;
using TeamUp.SharedKernel.Persistence;
namespace TeamUp.Modules.Governance.Persistence;
internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext> options)
: DbContext(options), IModuleDbContext
{
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("governance");
modelBuilder.Entity<AuditEntry>(entry =>
{
entry.ToTable("audit_entries");
entry.HasKey(a => a.Id);
entry.Property(a => a.Action).HasMaxLength(100).IsRequired();
entry.Property(a => a.EntityType).HasMaxLength(100).IsRequired();
entry.Property(a => a.Details).HasMaxLength(2000);
entry.HasIndex(a => a.OccurredAtUtc);
entry.HasIndex(a => new { a.EntityType, a.EntityId });
});
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace TeamUp.Modules.Governance.Persistence;
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
internal sealed class GovernanceDbContextFactory : IDesignTimeDbContextFactory<GovernanceDbContext>
{
public GovernanceDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("ConnectionStrings__Postgres")
?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup";
var options = new DbContextOptionsBuilder<GovernanceDbContext>()
.UseNpgsql(connectionString)
.Options;
return new GovernanceDbContext(options);
}
}
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.Governance.Persistence;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
[DbContext(typeof(GovernanceDbContext))]
[Migration("20260609084417_InitialGovernance")]
partial class InitialGovernance
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("governance")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("Details")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OccurredAtUtc");
b.HasIndex("EntityType", "EntityId");
b.ToTable("audit_entries", "governance");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialGovernance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "governance");
migrationBuilder.CreateTable(
name: "audit_entries",
schema: "governance",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Action = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EntityType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
EntityId = table.Column<Guid>(type: "uuid", nullable: false),
ActorMemberId = table.Column<Guid>(type: "uuid", nullable: true),
Details = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
OccurredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_audit_entries", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_audit_entries_EntityType_EntityId",
schema: "governance",
table: "audit_entries",
columns: new[] { "EntityType", "EntityId" });
migrationBuilder.CreateIndex(
name: "IX_audit_entries_OccurredAtUtc",
schema: "governance",
table: "audit_entries",
column: "OccurredAtUtc");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "audit_entries",
schema: "governance");
}
}
}
@@ -0,0 +1,66 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.Governance.Persistence;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
[DbContext(typeof(GovernanceDbContext))]
partial class GovernanceDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("governance")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.AuditEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("Details")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid>("EntityId")
.HasColumnType("uuid");
b.Property<string>("EntityType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OccurredAtUtc");
b.HasIndex("EntityType", "EntityId");
b.ToTable("audit_entries", "governance");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,10 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
gains an (internal) DbContext and validators. It must never reference another module. -->
<!-- Autonomy, the action gate, the review inbox, and the audit log. In M1 it implements the
shared IAuditLog (append-only audit). References SharedKernel only. -->
<ItemGroup>
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>
@@ -8,6 +8,7 @@ using TeamUp.Modules.Identity.Auth;
using TeamUp.Modules.Identity.Domain;
using TeamUp.Modules.Identity.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Identity.Endpoints;
@@ -101,6 +102,7 @@ internal static class IdentityEndpoints
InviteRequest request,
ICurrentUser currentUser,
IPermissionService permissions,
IAuditLog audit,
IdentityDbContext db,
TimeProvider clock,
CancellationToken ct)
@@ -127,6 +129,8 @@ internal static class IdentityEndpoints
db.Invitations.Add(invitation);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(
new AuditEvent("invitation.created", "Invitation", invitation.Id, currentUser.MemberId, request.Email.Trim()), ct);
return Results.Ok(new InviteResponse(invitation.Id, token));
}
@@ -136,6 +140,7 @@ internal static class IdentityEndpoints
IdentityDbContext db,
IPasswordHasher<Member> hasher,
JwtTokenService tokens,
IAuditLog audit,
TimeProvider clock,
CancellationToken ct)
{
@@ -159,6 +164,7 @@ internal static class IdentityEndpoints
db.Members.Add(member);
db.Memberships.Add(membership);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("member.joined", "Member", member.Id, member.Id, member.Email), ct);
return Results.Ok(new AuthResponse(tokens.Issue(member, [membership]), member.Id));
}
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.OrgBoard.Endpoints;
@@ -61,8 +62,8 @@ internal static class OrgBoardEndpoints
}
private static async Task<IResult> CreateTeam(
CreateTeamRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
CreateTeamRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
{
@@ -82,6 +83,7 @@ internal static class OrgBoardEndpoints
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
db.Teams.Add(team);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
}
@@ -104,7 +106,7 @@ internal static class OrgBoardEndpoints
private static async Task<IResult> CreateTask(
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
if (team is null)
@@ -125,6 +127,7 @@ internal static class OrgBoardEndpoints
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
db.WorkItems.Add(item);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.created", "WorkItem", item.Id, user.MemberId, item.Title), ct);
return Results.Ok(ToResponse(item));
}
@@ -153,8 +156,8 @@ internal static class OrgBoardEndpoints
}
private static async Task<IResult> MoveTask(
Guid id, MoveTaskRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
Guid id, MoveTaskRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
if (error is not null)
@@ -169,12 +172,13 @@ internal static class OrgBoardEndpoints
item!.MoveTo(request.Status, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
return Results.Ok(ToResponse(item));
}
private static async Task<IResult> AssignTask(
Guid id, AssignTaskRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
Guid id, AssignTaskRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
if (error is not null)
@@ -189,6 +193,7 @@ internal static class OrgBoardEndpoints
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.assigned", "WorkItem", item.Id, user.MemberId, request.MemberId.ToString()), ct);
return Results.Ok(ToResponse(item));
}