diff --git a/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs b/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs
new file mode 100644
index 0000000..c9432b7
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Auditing/AuditLog.cs
@@ -0,0 +1,25 @@
+using TeamUp.Modules.Governance.Domain;
+using TeamUp.Modules.Governance.Persistence;
+using TeamUp.SharedKernel.Auditing;
+
+namespace TeamUp.Modules.Governance.Auditing;
+
+///
+/// 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.
+///
+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);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs b/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs
new file mode 100644
index 0000000..777e853
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Domain/AuditEntry.cs
@@ -0,0 +1,34 @@
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Governance.Domain;
+
+/// An immutable audit record. Append-only — never updated or deleted.
+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;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
new file mode 100644
index 0000000..bbde94a
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
@@ -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 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);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
index c46f420..b7bccef 100644
--- a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
+++ b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
@@ -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;
-/// Autonomy dial, the action gate, the review inbox, the audit log (M5).
+/// Autonomy dial, the action gate, the review inbox, the audit log (M5). M1 ships the audit log.
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(options => options.UseNpgsql(connectionString));
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped();
+ 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);
}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
new file mode 100644
index 0000000..637a598
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
@@ -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 options)
+ : DbContext(options), IModuleDbContext
+{
+ public DbSet AuditEntries => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("governance");
+
+ modelBuilder.Entity(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 });
+ });
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs
new file mode 100644
index 0000000..c480e93
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContextFactory.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace TeamUp.Modules.Governance.Persistence;
+
+/// Design-time factory so `dotnet ef` can build the internal context without a host.
+internal sealed class GovernanceDbContextFactory : IDesignTimeDbContextFactory
+{
+ 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()
+ .UseNpgsql(connectionString)
+ .Options;
+
+ return new GovernanceDbContext(options);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs
new file mode 100644
index 0000000..07d0a63
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.Designer.cs
@@ -0,0 +1,69 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ActorMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Details")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs
new file mode 100644
index 0000000..c391468
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260609084417_InitialGovernance.cs
@@ -0,0 +1,56 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TeamUp.Modules.Governance.Persistence.Migrations
+{
+ ///
+ public partial class InitialGovernance : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "governance");
+
+ migrationBuilder.CreateTable(
+ name: "audit_entries",
+ schema: "governance",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Action = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ EntityType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ EntityId = table.Column(type: "uuid", nullable: false),
+ ActorMemberId = table.Column(type: "uuid", nullable: true),
+ Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true),
+ OccurredAtUtc = table.Column(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");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "audit_entries",
+ schema: "governance");
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
new file mode 100644
index 0000000..e3ce041
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
@@ -0,0 +1,66 @@
+//
+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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ActorMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Details")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj b/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj
index 65f5856..58a70e9 100644
--- a/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj
+++ b/src/Modules/TeamUp.Modules.Governance/TeamUp.Modules.Governance.csproj
@@ -1,10 +1,15 @@
-
+
+
+
+
+
+
+
diff --git a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs
index d11d887..32639f9 100644
--- a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs
+++ b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs
@@ -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 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));
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
index 8627648..aa46d03 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs
@@ -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 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 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 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 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));
}
diff --git a/src/Shared/TeamUp.SharedKernel/Auditing/IAuditLog.cs b/src/Shared/TeamUp.SharedKernel/Auditing/IAuditLog.cs
new file mode 100644
index 0000000..b77306f
--- /dev/null
+++ b/src/Shared/TeamUp.SharedKernel/Auditing/IAuditLog.cs
@@ -0,0 +1,18 @@
+namespace TeamUp.SharedKernel.Auditing;
+
+/// An immutable record of a meaningful action. Written by any module via .
+public sealed record AuditEvent(
+ string Action,
+ string EntityType,
+ Guid EntityId,
+ Guid? ActorMemberId,
+ string? Details = null);
+
+///
+/// The append-only audit log. Defined in SharedKernel and implemented by Governance, so any module
+/// can record an action without referencing Governance.
+///
+public interface IAuditLog
+{
+ Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default);
+}
diff --git a/src/Shared/TeamUp.SharedKernel/Metrics/EditDistance.cs b/src/Shared/TeamUp.SharedKernel/Metrics/EditDistance.cs
new file mode 100644
index 0000000..162857a
--- /dev/null
+++ b/src/Shared/TeamUp.SharedKernel/Metrics/EditDistance.cs
@@ -0,0 +1,56 @@
+namespace TeamUp.SharedKernel.Metrics;
+
+///
+/// The product's north-star metric: how much a reviewer changes AI output before approving.
+/// Levenshtein edit distance, available from day one (M1). It is consumed at edit-and-approve in
+/// the review inbox (M5); kept here so the data path and computation exist before there is AI
+/// output to measure.
+///
+public static class EditDistance
+{
+ /// Levenshtein distance — the minimum single-character edits to turn into .
+ public static int Levenshtein(string a, string b)
+ {
+ ArgumentNullException.ThrowIfNull(a);
+ ArgumentNullException.ThrowIfNull(b);
+
+ if (a.Length == 0)
+ {
+ return b.Length;
+ }
+
+ if (b.Length == 0)
+ {
+ return a.Length;
+ }
+
+ var previous = new int[b.Length + 1];
+ var current = new int[b.Length + 1];
+
+ for (var j = 0; j <= b.Length; j++)
+ {
+ previous[j] = j;
+ }
+
+ for (var i = 1; i <= a.Length; i++)
+ {
+ current[0] = i;
+ for (var j = 1; j <= b.Length; j++)
+ {
+ var cost = a[i - 1] == b[j - 1] ? 0 : 1;
+ current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);
+ }
+
+ (previous, current) = (current, previous);
+ }
+
+ return previous[b.Length];
+ }
+
+ /// Edit distance normalized to [0,1] by the longer string. 0 = unchanged (accepted as-is).
+ public static double Normalized(string original, string edited)
+ {
+ var max = Math.Max(original.Length, edited.Length);
+ return max == 0 ? 0d : (double)Levenshtein(original, edited) / max;
+ }
+}
diff --git a/tests/TeamUp.IntegrationTests/BoardFlowTests.cs b/tests/TeamUp.IntegrationTests/BoardFlowTests.cs
index 7d4ab7e..0471f57 100644
--- a/tests/TeamUp.IntegrationTests/BoardFlowTests.cs
+++ b/tests/TeamUp.IntegrationTests/BoardFlowTests.cs
@@ -30,6 +30,10 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture Columns);
+ private sealed record AuditEntryResponse(
+ Guid Id, string Action, string EntityType, Guid EntityId,
+ Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc);
+
[Fact]
public async Task Owner_builds_board_and_member_is_scoped()
{
@@ -76,6 +80,12 @@ public sealed class BoardFlowTests(PostgresFixture postgres) : IClassFixture>("/api/orgboard/cartable");
Assert.Contains(cartable!, i => i.Id == task.Id);
+ // The audit log (owner-only) recorded the task actions.
+ var audit = await ownerClient.GetFromJsonAsync>(
+ $"/api/governance/audit?organizationId={owner.OrganizationId}");
+ Assert.Contains(audit!, e => e.Action == "task.created" && e.EntityId == task.Id);
+ Assert.Contains(audit!, e => e.Action == "task.moved" && e.EntityId == task.Id);
+
// Invite a Member at the org scope and accept.
var invite = await PostOk(ownerClient, "/api/identity/invitations", new
{
diff --git a/tests/TeamUp.IntegrationTests/EditDistanceTests.cs b/tests/TeamUp.IntegrationTests/EditDistanceTests.cs
new file mode 100644
index 0000000..d14d107
--- /dev/null
+++ b/tests/TeamUp.IntegrationTests/EditDistanceTests.cs
@@ -0,0 +1,26 @@
+using TeamUp.SharedKernel.Metrics;
+using Xunit;
+
+namespace TeamUp.IntegrationTests;
+
+/// Unit coverage for the north-star metric helper (no database).
+public sealed class EditDistanceTests
+{
+ [Theory]
+ [InlineData("", "", 0)]
+ [InlineData("abc", "abc", 0)]
+ [InlineData("", "abc", 3)]
+ [InlineData("abc", "", 3)]
+ [InlineData("kitten", "sitting", 3)]
+ [InlineData("spec v1", "spec v2", 1)]
+ public void Levenshtein_counts_edits(string a, string b, int expected) =>
+ Assert.Equal(expected, EditDistance.Levenshtein(a, b));
+
+ [Fact]
+ public void Normalized_is_zero_when_unchanged() =>
+ Assert.Equal(0d, EditDistance.Normalized("approved as-is", "approved as-is"));
+
+ [Fact]
+ public void Normalized_is_between_zero_and_one() =>
+ Assert.InRange(EditDistance.Normalized("the AI wrote this", "the human edited this"), 0d, 1d);
+}