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,18 @@
namespace TeamUp.SharedKernel.Auditing;
/// <summary>An immutable record of a meaningful action. Written by any module via <see cref="IAuditLog"/>.</summary>
public sealed record AuditEvent(
string Action,
string EntityType,
Guid EntityId,
Guid? ActorMemberId,
string? Details = null);
/// <summary>
/// The append-only audit log. Defined in SharedKernel and implemented by Governance, so any module
/// can record an action without referencing Governance.
/// </summary>
public interface IAuditLog
{
Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,56 @@
namespace TeamUp.SharedKernel.Metrics;
/// <summary>
/// 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.
/// </summary>
public static class EditDistance
{
/// <summary>Levenshtein distance — the minimum single-character edits to turn <paramref name="a"/> into <paramref name="b"/>.</summary>
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];
}
/// <summary>Edit distance normalized to [0,1] by the longer string. 0 = unchanged (accepted as-is).</summary>
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;
}
}