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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user