diff --git a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs
index e6b8d94..0e73850 100644
--- a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs
+++ b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunExecutor.cs
@@ -12,7 +12,8 @@ internal sealed record AgentRunPayload(Guid RunId);
///
/// Processes one claimed job end to end: resolve the run context (OrgBoard) + skills (Skills) →
/// assemble the prompt → call the model (BYOK, with fallback) → parse into an action + risk tag,
-/// all captured on the AgentRun. Nothing executes off the parsed action — the gate is M5.
+/// all captured on the AgentRun — then hand the proposal to the action gate (Governance), which
+/// executes it or holds it in the review inbox.
///
internal sealed class AgentRunExecutor(
AssemblerDbContext db,
@@ -20,6 +21,7 @@ internal sealed class AgentRunExecutor(
ISkillCatalog skillCatalog,
IApiConfigResolver configResolver,
IModelClient modelClient,
+ IActionGate actionGate,
TimeProvider clock,
ILogger logger)
{
@@ -68,7 +70,21 @@ internal sealed class AgentRunExecutor(
skill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null,
});
- run.Complete(completion.Text ?? string.Empty, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
+ var output = completion.Text ?? string.Empty;
+ run.Complete(output, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
+ await db.SaveChangesAsync(cancellationToken);
+
+ // Hand the parsed action to the gate: autonomy vs risk → execute now or hold in review.
+ var gate = await actionGate.EvaluateAsync(
+ new AgentActionProposal(
+ run.Id, run.SeatId, context.AgentId, run.WorkItemId, context.TeamId, context.OrganizationId,
+ context.Autonomy, assembled.PrimaryAction, assembled.PrimaryActionRisk,
+ context.TaskTitle, output, OutputParser.ExtractChildTitles(output), assembled.Trace),
+ cancellationToken);
+ logger.LogInformation(
+ "Run {RunId}: {Action} ({Risk}) → {Outcome}.",
+ run.Id, assembled.PrimaryAction, assembled.PrimaryActionRisk, gate.Outcome);
+
job.MarkDone(clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken);
}
diff --git a/src/Modules/TeamUp.Modules.Assembler/Runtime/OutputParser.cs b/src/Modules/TeamUp.Modules.Assembler/Runtime/OutputParser.cs
new file mode 100644
index 0000000..18fb706
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Assembler/Runtime/OutputParser.cs
@@ -0,0 +1,25 @@
+using System.Text.RegularExpressions;
+
+namespace TeamUp.Modules.Assembler.Runtime;
+
+///
+/// Extracts proposed child-task titles from model output: top-level numbered list items
+/// ("1. …" / "2) …"). Deterministic and conservative — anything unparsed simply yields no
+/// children, and the reviewer can add/edit them in the review inbox before approving.
+///
+internal static partial class OutputParser
+{
+ private const int MaxChildren = 10;
+ private const int MaxTitleLength = 300;
+
+ [GeneratedRegex(@"^\s*\d{1,2}[\.\)]\s+(?.+?)\s*$", RegexOptions.Multiline)]
+ private static partial Regex NumberedLine();
+
+ public static IReadOnlyList ExtractChildTitles(string output) =>
+ NumberedLine().Matches(output)
+ .Select(match => match.Groups["title"].Value.Trim())
+ .Where(title => title.Length > 0)
+ .Take(MaxChildren)
+ .Select(title => title.Length > MaxTitleLength ? title[..MaxTitleLength] : title)
+ .ToList();
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Domain/ReviewItem.cs b/src/Modules/TeamUp.Modules.Governance/Domain/ReviewItem.cs
new file mode 100644
index 0000000..bddd9ed
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Domain/ReviewItem.cs
@@ -0,0 +1,78 @@
+using TeamUp.SharedKernel.Ai;
+using TeamUp.SharedKernel.Domain;
+
+namespace TeamUp.Modules.Governance.Domain;
+
+internal enum ReviewStatus
+{
+ Pending,
+ Approved,
+ SentBack,
+}
+
+///
+/// A held agent action waiting in the review inbox. Carries the proposed artifact (editable) and
+/// the reasoning trace; on approval it records the human edit distance — the north-star metric.
+///
+internal sealed class ReviewItem : Entity
+{
+ public Guid OrganizationId { get; private set; }
+ public Guid TeamId { get; private set; }
+ public Guid AgentRunId { get; private set; }
+ public Guid SeatId { get; private set; }
+ public Guid AgentId { get; private set; }
+ public Guid WorkItemId { get; private set; }
+ public string ActionKind { get; private set; } = null!;
+ public string Risk { get; private set; } = null!;
+ public string Title { get; private set; } = null!;
+ public string Content { get; private set; } = null!;
+ public List ChildTitles { get; private set; } = [];
+ public string? Trace { get; private set; }
+ public ReviewStatus Status { get; private set; }
+ public string? Decision { get; private set; }
+ public double? EditDistance { get; private set; }
+ public Guid? DecidedByMemberId { get; private set; }
+ public DateTimeOffset CreatedAtUtc { get; private set; }
+ public DateTimeOffset? DecidedAtUtc { get; private set; }
+
+ private ReviewItem()
+ {
+ }
+
+ public ReviewItem(AgentActionProposal proposal, DateTimeOffset createdAtUtc)
+ {
+ OrganizationId = proposal.OrganizationId;
+ TeamId = proposal.TeamId;
+ AgentRunId = proposal.AgentRunId;
+ SeatId = proposal.SeatId;
+ AgentId = proposal.AgentId;
+ WorkItemId = proposal.WorkItemId;
+ ActionKind = proposal.ActionKind;
+ Risk = proposal.Risk;
+ Title = proposal.Title;
+ Content = proposal.Content;
+ ChildTitles = proposal.ChildTitles.ToList();
+ Trace = proposal.Trace;
+ Status = ReviewStatus.Pending;
+ CreatedAtUtc = createdAtUtc;
+ }
+
+ public void Approve(string finalContent, List finalChildTitles, double editDistance, bool edited, Guid memberId, DateTimeOffset nowUtc)
+ {
+ Content = finalContent;
+ ChildTitles = finalChildTitles;
+ EditDistance = editDistance;
+ Status = ReviewStatus.Approved;
+ Decision = edited ? "EditedAndApproved" : "Approved";
+ DecidedByMemberId = memberId;
+ DecidedAtUtc = nowUtc;
+ }
+
+ public void SendBack(Guid memberId, DateTimeOffset nowUtc)
+ {
+ Status = ReviewStatus.SentBack;
+ Decision = "SentBack";
+ DecidedByMemberId = memberId;
+ DecidedAtUtc = nowUtc;
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
index bbde94a..b31d1cb 100644
--- a/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
+++ b/src/Modules/TeamUp.Modules.Governance/Endpoints/GovernanceEndpoints.cs
@@ -2,8 +2,12 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.Governance.Domain;
+using TeamUp.Modules.Governance.Gate;
using TeamUp.Modules.Governance.Persistence;
using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Auditing;
+using TeamUp.SharedKernel.Metrics;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Governance.Endpoints;
@@ -17,6 +21,26 @@ internal sealed record AuditEntryResponse(
string? Details,
DateTimeOffset OccurredAtUtc);
+internal sealed record ReviewItemResponse(
+ Guid Id,
+ Guid OrganizationId,
+ Guid TeamId,
+ Guid AgentRunId,
+ Guid AgentId,
+ Guid WorkItemId,
+ string ActionKind,
+ string Risk,
+ string Title,
+ string Content,
+ List ChildTitles,
+ string? Trace,
+ string Status,
+ string? Decision,
+ double? EditDistance,
+ DateTimeOffset CreatedAtUtc);
+
+internal sealed record ApproveRequest(string? Content, List? ChildTitles);
+
internal static class GovernanceEndpoints
{
public static void Map(IEndpointRouteBuilder endpoints)
@@ -25,8 +49,16 @@ internal static class GovernanceEndpoints
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("governance")));
group.MapGet("/audit", GetAudit).RequireAuthorization();
+ group.MapGet("/reviews", ListReviews).RequireAuthorization();
+ group.MapPost("/reviews/{id:guid}/approve", Approve).RequireAuthorization();
+ group.MapPost("/reviews/{id:guid}/sendback", SendBack).RequireAuthorization();
}
+ private static ReviewItemResponse ToResponse(ReviewItem item) => new(
+ item.Id, item.OrganizationId, item.TeamId, item.AgentRunId, item.AgentId, item.WorkItemId,
+ item.ActionKind, item.Risk, item.Title, item.Content, item.ChildTitles, item.Trace,
+ item.Status.ToString(), item.Decision, item.EditDistance, item.CreatedAtUtc);
+
private static async Task GetAudit(
Guid organizationId, int? take, IPermissionService permissions, GovernanceDbContext db, CancellationToken ct)
{
@@ -46,4 +78,102 @@ internal static class GovernanceEndpoints
return Results.Ok(entries);
}
+
+ // The review inbox = the Approvals section of an approver's cartable. Items are filtered to
+ // the scopes where the caller may approve (org owner sees all; a team owner their teams).
+ private static async Task ListReviews(
+ Guid organizationId, string? status, IPermissionService permissions,
+ GovernanceDbContext db, CancellationToken ct)
+ {
+ var wanted = Enum.TryParse(status, ignoreCase: true, out var parsed)
+ ? parsed
+ : ReviewStatus.Pending;
+
+ var items = await db.ReviewItems
+ .Where(r => r.OrganizationId == organizationId && r.Status == wanted)
+ .OrderByDescending(r => r.CreatedAtUtc)
+ .ToListAsync(ct);
+
+ var visible = items
+ .Where(r => permissions.Has(
+ Capability.ApproveHeldActions, ScopeRef.Team(r.TeamId), ScopeRef.Org(r.OrganizationId)))
+ .Select(ToResponse)
+ .ToList();
+
+ return Results.Ok(visible);
+ }
+
+ private static async Task Approve(
+ Guid id, ApproveRequest request, ICurrentUser user, IPermissionService permissions,
+ HeldActionExecutor executor, IAuditLog audit, GovernanceDbContext db,
+ TimeProvider clock, CancellationToken ct)
+ {
+ var item = await db.ReviewItems.FirstOrDefaultAsync(r => r.Id == id, ct);
+ if (item is null)
+ {
+ return Results.NotFound();
+ }
+
+ if (!permissions.Has(Capability.ApproveHeldActions, ScopeRef.Team(item.TeamId), ScopeRef.Org(item.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ if (item.Status != ReviewStatus.Pending)
+ {
+ return Results.Conflict("This item has already been decided.");
+ }
+
+ var finalContent = request.Content ?? item.Content;
+ var finalChildren = request.ChildTitles ?? item.ChildTitles;
+
+ // Human edit distance — the north-star metric — over the full editable artifact.
+ var original = item.Content + "\n" + string.Join("\n", item.ChildTitles);
+ var final = finalContent + "\n" + string.Join("\n", finalChildren);
+ var distance = EditDistance.Normalized(original, final);
+ var edited = distance > 0;
+
+ item.Approve(finalContent, finalChildren.ToList(), distance, edited, user.MemberId, clock.GetUtcNow());
+ await db.SaveChangesAsync(ct);
+
+ // Execute the approved action onto the board (artifact + child tasks).
+ await executor.ExecuteAsync(item.TeamId, item.WorkItemId, finalContent, finalChildren, user.MemberId, ct);
+
+ await audit.WriteAsync(
+ new AuditEvent(
+ edited ? "review.edited-approved" : "review.approved",
+ "ReviewItem", item.Id, user.MemberId,
+ $"{item.ActionKind} editDistance={distance:F3} children={finalChildren.Count}"),
+ ct);
+
+ return Results.Ok(ToResponse(item));
+ }
+
+ private static async Task SendBack(
+ Guid id, ICurrentUser user, IPermissionService permissions,
+ IAuditLog audit, GovernanceDbContext db, TimeProvider clock, CancellationToken ct)
+ {
+ var item = await db.ReviewItems.FirstOrDefaultAsync(r => r.Id == id, ct);
+ if (item is null)
+ {
+ return Results.NotFound();
+ }
+
+ if (!permissions.Has(Capability.ApproveHeldActions, ScopeRef.Team(item.TeamId), ScopeRef.Org(item.OrganizationId)))
+ {
+ return Results.Forbid();
+ }
+
+ if (item.Status != ReviewStatus.Pending)
+ {
+ return Results.Conflict("This item has already been decided.");
+ }
+
+ item.SendBack(user.MemberId, clock.GetUtcNow());
+ await db.SaveChangesAsync(ct);
+ await audit.WriteAsync(
+ new AuditEvent("review.sentback", "ReviewItem", item.Id, user.MemberId, item.ActionKind), ct);
+
+ return Results.Ok(ToResponse(item));
+ }
}
diff --git a/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs b/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs
new file mode 100644
index 0000000..f5351de
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs
@@ -0,0 +1,47 @@
+using TeamUp.Modules.Governance.Domain;
+using TeamUp.Modules.Governance.Persistence;
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Ai;
+using TeamUp.SharedKernel.Auditing;
+
+namespace TeamUp.Modules.Governance.Gate;
+
+///
+/// The action gate: compares the seat's autonomy to the action's risk. Execute now (autonomous +
+/// non-destructive) or hold as a in the review inbox. Every decision is
+/// audited. Destructive always holds — GatePolicy is the backstop.
+///
+internal sealed class ActionGate(
+ GovernanceDbContext db,
+ HeldActionExecutor executor,
+ IAuditLog audit,
+ TimeProvider clock) : IActionGate
+{
+ public async Task EvaluateAsync(AgentActionProposal proposal, CancellationToken cancellationToken = default)
+ {
+ var risk = Enum.TryParse(proposal.Risk, ignoreCase: true, out var parsed)
+ ? parsed
+ : ActionRisk.Draft; // unknown risk is treated as Draft → held unless autonomous
+
+ if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
+ {
+ var item = new ReviewItem(proposal, clock.GetUtcNow());
+ db.ReviewItems.Add(item);
+ await db.SaveChangesAsync(cancellationToken);
+ await audit.WriteAsync(
+ new AuditEvent("action.held", "ReviewItem", item.Id, null,
+ $"{proposal.ActionKind} ({proposal.Risk}) by agent {proposal.AgentId}"),
+ cancellationToken);
+ return new GateResult(GateOutcome.Held, item.Id);
+ }
+
+ await executor.ExecuteAsync(
+ proposal.TeamId, proposal.WorkItemId, proposal.Content, proposal.ChildTitles,
+ actedByMemberId: null, cancellationToken);
+ await audit.WriteAsync(
+ new AuditEvent("action.executed", "AgentRun", proposal.AgentRunId, null,
+ $"{proposal.ActionKind} ({proposal.Risk}) autonomous"),
+ cancellationToken);
+ return new GateResult(GateOutcome.Executed, null);
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Gate/HeldActionExecutor.cs b/src/Modules/TeamUp.Modules.Governance/Gate/HeldActionExecutor.cs
new file mode 100644
index 0000000..9d47018
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Gate/HeldActionExecutor.cs
@@ -0,0 +1,30 @@
+using TeamUp.SharedKernel.Board;
+
+namespace TeamUp.Modules.Governance.Gate;
+
+///
+/// Performs the internal action behind an agent proposal: write the artifact onto the task and
+/// create the proposed child tasks. Used by the gate (autonomous path) and the approve endpoint.
+///
+internal sealed class HeldActionExecutor(IBoardWriter boardWriter)
+{
+ public async Task ExecuteAsync(
+ Guid teamId,
+ Guid workItemId,
+ string content,
+ IReadOnlyList childTitles,
+ Guid? actedByMemberId,
+ CancellationToken cancellationToken = default)
+ {
+ if (!string.IsNullOrWhiteSpace(content))
+ {
+ await boardWriter.AttachArtifactAsync(workItemId, content, cancellationToken);
+ }
+
+ if (childTitles.Count > 0)
+ {
+ var children = childTitles.Select(title => new ChildTaskSpec(title, "Story")).ToList();
+ await boardWriter.CreateChildTasksAsync(teamId, workItemId, children, actedByMemberId, cancellationToken);
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
index b7bccef..8d9bd13 100644
--- a/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
+++ b/src/Modules/TeamUp.Modules.Governance/GovernanceModule.cs
@@ -5,7 +5,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TeamUp.Modules.Governance.Auditing;
using TeamUp.Modules.Governance.Endpoints;
+using TeamUp.Modules.Governance.Gate;
using TeamUp.Modules.Governance.Persistence;
+using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
@@ -25,6 +27,8 @@ public sealed class GovernanceModule : IModule
services.AddDbContext(options => options.UseNpgsql(connectionString));
services.AddScoped(sp => sp.GetRequiredService());
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.TryAddSingleton(TimeProvider.System);
}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
index 637a598..3043d18 100644
--- a/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/GovernanceDbContext.cs
@@ -8,6 +8,7 @@ internal sealed class GovernanceDbContext(DbContextOptions
: DbContext(options), IModuleDbContext
{
public DbSet AuditEntries => Set();
+ public DbSet ReviewItems => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -23,5 +24,18 @@ internal sealed class GovernanceDbContext(DbContextOptions
entry.HasIndex(a => a.OccurredAtUtc);
entry.HasIndex(a => new { a.EntityType, a.EntityId });
});
+
+ modelBuilder.Entity(item =>
+ {
+ item.ToTable("review_items");
+ item.HasKey(r => r.Id);
+ item.Property(r => r.ActionKind).HasMaxLength(60).IsRequired();
+ item.Property(r => r.Risk).HasMaxLength(20).IsRequired();
+ item.Property(r => r.Title).HasMaxLength(300).IsRequired();
+ item.Property(r => r.Status).HasConversion().HasMaxLength(20);
+ item.Property(r => r.Decision).HasMaxLength(30);
+ item.HasIndex(r => new { r.OrganizationId, r.Status });
+ item.HasIndex(r => r.AgentRunId);
+ });
}
}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.Designer.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.Designer.cs
new file mode 100644
index 0000000..1eeb561
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.Designer.cs
@@ -0,0 +1,150 @@
+//
+using System;
+using System.Collections.Generic;
+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("20260610041006_AddReviewItems")]
+ partial class AddReviewItems
+ {
+ ///
+ 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");
+ });
+
+ modelBuilder.Entity("TeamUp.Modules.Governance.Domain.ReviewItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActionKind")
+ .IsRequired()
+ .HasMaxLength(60)
+ .HasColumnType("character varying(60)");
+
+ b.Property("AgentId")
+ .HasColumnType("uuid");
+
+ b.Property("AgentRunId")
+ .HasColumnType("uuid");
+
+ b.PrimitiveCollection>("ChildTitles")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedByMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Decision")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EditDistance")
+ .HasColumnType("double precision");
+
+ b.Property("OrganizationId")
+ .HasColumnType("uuid");
+
+ b.Property("Risk")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("SeatId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("Trace")
+ .HasColumnType("text");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AgentRunId");
+
+ b.HasIndex("OrganizationId", "Status");
+
+ b.ToTable("review_items", "governance");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.cs
new file mode 100644
index 0000000..4ff7526
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace TeamUp.Modules.Governance.Persistence.Migrations
+{
+ ///
+ public partial class AddReviewItems : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "review_items",
+ schema: "governance",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ OrganizationId = table.Column(type: "uuid", nullable: false),
+ TeamId = table.Column(type: "uuid", nullable: false),
+ AgentRunId = table.Column(type: "uuid", nullable: false),
+ SeatId = table.Column(type: "uuid", nullable: false),
+ AgentId = table.Column(type: "uuid", nullable: false),
+ WorkItemId = table.Column(type: "uuid", nullable: false),
+ ActionKind = table.Column(type: "character varying(60)", maxLength: 60, nullable: false),
+ Risk = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false),
+ Content = table.Column(type: "text", nullable: false),
+ ChildTitles = table.Column>(type: "text[]", nullable: false),
+ Trace = table.Column(type: "text", nullable: true),
+ Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false),
+ Decision = table.Column(type: "character varying(30)", maxLength: 30, nullable: true),
+ EditDistance = table.Column(type: "double precision", nullable: true),
+ DecidedByMemberId = table.Column(type: "uuid", nullable: true),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ DecidedAtUtc = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_review_items", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_review_items_AgentRunId",
+ schema: "governance",
+ table: "review_items",
+ column: "AgentRunId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_review_items_OrganizationId_Status",
+ schema: "governance",
+ table: "review_items",
+ columns: new[] { "OrganizationId", "Status" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "review_items",
+ schema: "governance");
+ }
+ }
+}
diff --git a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
index e3ce041..0c58671 100644
--- a/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
+++ b/src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/GovernanceDbContextModelSnapshot.cs
@@ -1,5 +1,6 @@
//
using System;
+using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -60,6 +61,86 @@ namespace TeamUp.Modules.Governance.Persistence.Migrations
b.ToTable("audit_entries", "governance");
});
+
+ modelBuilder.Entity("TeamUp.Modules.Governance.Domain.ReviewItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActionKind")
+ .IsRequired()
+ .HasMaxLength(60)
+ .HasColumnType("character varying(60)");
+
+ b.Property("AgentId")
+ .HasColumnType("uuid");
+
+ b.Property("AgentRunId")
+ .HasColumnType("uuid");
+
+ b.PrimitiveCollection>("ChildTitles")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedByMemberId")
+ .HasColumnType("uuid");
+
+ b.Property("Decision")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EditDistance")
+ .HasColumnType("double precision");
+
+ b.Property("OrganizationId")
+ .HasColumnType("uuid");
+
+ b.Property("Risk")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("SeatId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("Trace")
+ .HasColumnType("text");
+
+ b.Property("WorkItemId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AgentRunId");
+
+ b.HasIndex("OrganizationId", "Status");
+
+ b.ToTable("review_items", "governance");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs
index 31e793b..ae5dd57 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/WorkItem.cs
@@ -61,4 +61,13 @@ internal sealed class WorkItem : Entity
AssigneeId = null;
UpdatedAtUtc = nowUtc;
}
+
+ /// Appends an approved agent artifact (spec / test plan) to the task.
+ public void AttachArtifact(string content, DateTimeOffset nowUtc)
+ {
+ Description = string.IsNullOrWhiteSpace(Description)
+ ? content
+ : Description + "\n\n---\n\n" + content;
+ UpdatedAtUtc = nowUtc;
+ }
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs
index 38c5493..20e3bb7 100644
--- a/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs
+++ b/src/Modules/TeamUp.Modules.OrgBoard/OrgBoardModule.cs
@@ -7,6 +7,7 @@ using TeamUp.Modules.OrgBoard.Endpoints;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.Modules.OrgBoard.Runtime;
using TeamUp.SharedKernel.Ai;
+using TeamUp.SharedKernel.Board;
using TeamUp.SharedKernel.Modularity;
using TeamUp.SharedKernel.Persistence;
@@ -25,6 +26,7 @@ public sealed class OrgBoardModule : IModule
services.AddDbContext(options => options.UseNpgsql(connectionString));
services.AddScoped(sp => sp.GetRequiredService());
services.AddScoped();
+ services.AddScoped();
services.TryAddSingleton(TimeProvider.System);
}
diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Runtime/BoardWriter.cs b/src/Modules/TeamUp.Modules.OrgBoard/Runtime/BoardWriter.cs
new file mode 100644
index 0000000..5223b4c
--- /dev/null
+++ b/src/Modules/TeamUp.Modules.OrgBoard/Runtime/BoardWriter.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore;
+using TeamUp.Modules.OrgBoard.Domain;
+using TeamUp.Modules.OrgBoard.Persistence;
+using TeamUp.SharedKernel.Board;
+
+namespace TeamUp.Modules.OrgBoard.Runtime;
+
+/// Writes approved agent actions onto the board — creating child tasks under a parent.
+internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IBoardWriter
+{
+ public async Task CreateChildTasksAsync(
+ Guid teamId,
+ Guid parentWorkItemId,
+ IReadOnlyList children,
+ Guid? createdByMemberId,
+ CancellationToken cancellationToken = default)
+ {
+ var now = clock.GetUtcNow();
+ var creator = createdByMemberId ?? Guid.Empty;
+
+ foreach (var child in children)
+ {
+ var type = Enum.TryParse(child.Type, ignoreCase: true, out var parsed)
+ ? parsed
+ : WorkItemType.Story;
+ db.WorkItems.Add(new WorkItem(teamId, child.Title, description: null, type, creator, now, parentWorkItemId));
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+ return children.Count;
+ }
+
+ public async Task AttachArtifactAsync(Guid workItemId, string content, CancellationToken cancellationToken = default)
+ {
+ var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken)
+ ?? throw new InvalidOperationException($"Work item {workItemId} not found.");
+
+ item.AttachArtifact(content, clock.GetUtcNow());
+ await db.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Shared/TeamUp.SharedKernel/Access/ActionRisk.cs b/src/Shared/TeamUp.SharedKernel/Access/ActionRisk.cs
new file mode 100644
index 0000000..3620720
--- /dev/null
+++ b/src/Shared/TeamUp.SharedKernel/Access/ActionRisk.cs
@@ -0,0 +1,13 @@
+namespace TeamUp.SharedKernel.Access;
+
+///
+/// Risk lives on the action, not the agent. The action gate (Governance, M5) compares it to the
+/// seat's autonomy to decide execute-vs-hold. Destructive always holds for a human.
+///
+public enum ActionRisk
+{
+ Read,
+ Draft,
+ Publish,
+ Destructive,
+}
diff --git a/src/Shared/TeamUp.SharedKernel/Access/GatePolicy.cs b/src/Shared/TeamUp.SharedKernel/Access/GatePolicy.cs
new file mode 100644
index 0000000..f0c9ffb
--- /dev/null
+++ b/src/Shared/TeamUp.SharedKernel/Access/GatePolicy.cs
@@ -0,0 +1,18 @@
+namespace TeamUp.SharedKernel.Access;
+
+///
+/// The action-gate decision matrix: seat autonomy × action risk → execute or hold. Pure policy —
+/// persistence/execution live in Governance. Destructive ALWAYS holds for a human, whatever the
+/// autonomy (the prompt-injection backstop). Read-level actions never hold. Draft/Publish execute
+/// only on an Autonomous seat; DraftOnly and Gated behave alike on V1's internal action set (the
+/// distinction becomes meaningful with per-agent MCP tool-calls in Phase 1).
+///
+public static class GatePolicy
+{
+ public static bool ShouldHold(Autonomy autonomy, ActionRisk risk) => risk switch
+ {
+ ActionRisk.Read => false,
+ ActionRisk.Destructive => true,
+ _ => autonomy != Autonomy.Autonomous,
+ };
+}
diff --git a/src/Shared/TeamUp.SharedKernel/Ai/IActionGate.cs b/src/Shared/TeamUp.SharedKernel/Ai/IActionGate.cs
new file mode 100644
index 0000000..de832a0
--- /dev/null
+++ b/src/Shared/TeamUp.SharedKernel/Ai/IActionGate.cs
@@ -0,0 +1,36 @@
+using TeamUp.SharedKernel.Access;
+
+namespace TeamUp.SharedKernel.Ai;
+
+/// An agent's proposed action coming off a completed run, handed to the action gate.
+public sealed record AgentActionProposal(
+ Guid AgentRunId,
+ Guid SeatId,
+ Guid AgentId,
+ Guid WorkItemId,
+ Guid TeamId,
+ Guid OrganizationId,
+ Autonomy Autonomy,
+ string ActionKind,
+ string Risk,
+ string Title,
+ string Content,
+ IReadOnlyList ChildTitles,
+ string? Trace);
+
+public enum GateOutcome
+{
+ Executed,
+ Held,
+}
+
+public sealed record GateResult(GateOutcome Outcome, Guid? ReviewItemId);
+
+///
+/// The action gate: autonomy vs risk → execute now or hold for review. Implemented by Governance;
+/// called by the assembler when a run completes. Destructive always holds.
+///
+public interface IActionGate
+{
+ Task EvaluateAsync(AgentActionProposal proposal, CancellationToken cancellationToken = default);
+}
diff --git a/src/Shared/TeamUp.SharedKernel/Board/IBoardWriter.cs b/src/Shared/TeamUp.SharedKernel/Board/IBoardWriter.cs
new file mode 100644
index 0000000..47df19a
--- /dev/null
+++ b/src/Shared/TeamUp.SharedKernel/Board/IBoardWriter.cs
@@ -0,0 +1,20 @@
+namespace TeamUp.SharedKernel.Board;
+
+public sealed record ChildTaskSpec(string Title, string Type);
+
+///
+/// Lets Governance execute an approved agent action onto the board (create the child tasks)
+/// without referencing OrgBoard's tables. Implemented by OrgBoard.
+///
+public interface IBoardWriter
+{
+ Task CreateChildTasksAsync(
+ Guid teamId,
+ Guid parentWorkItemId,
+ IReadOnlyList children,
+ Guid? createdByMemberId,
+ CancellationToken cancellationToken = default);
+
+ /// Writes an approved artifact (spec / test plan) onto the work item.
+ Task AttachArtifactAsync(Guid workItemId, string content, CancellationToken cancellationToken = default);
+}
diff --git a/tests/TeamUp.IntegrationTests/GatePolicyTests.cs b/tests/TeamUp.IntegrationTests/GatePolicyTests.cs
new file mode 100644
index 0000000..d245255
--- /dev/null
+++ b/tests/TeamUp.IntegrationTests/GatePolicyTests.cs
@@ -0,0 +1,26 @@
+using TeamUp.SharedKernel.Access;
+using Xunit;
+
+namespace TeamUp.IntegrationTests;
+
+/// The gate decision matrix — pure policy, no database.
+public sealed class GatePolicyTests
+{
+ [Theory]
+ // Read never holds.
+ [InlineData(Autonomy.DraftOnly, ActionRisk.Read, false)]
+ [InlineData(Autonomy.Gated, ActionRisk.Read, false)]
+ [InlineData(Autonomy.Autonomous, ActionRisk.Read, false)]
+ // Draft/Publish hold unless the seat is Autonomous.
+ [InlineData(Autonomy.DraftOnly, ActionRisk.Draft, true)]
+ [InlineData(Autonomy.Gated, ActionRisk.Draft, true)]
+ [InlineData(Autonomy.Autonomous, ActionRisk.Draft, false)]
+ [InlineData(Autonomy.Gated, ActionRisk.Publish, true)]
+ [InlineData(Autonomy.Autonomous, ActionRisk.Publish, false)]
+ // Destructive ALWAYS holds — whatever the autonomy. The backstop.
+ [InlineData(Autonomy.DraftOnly, ActionRisk.Destructive, true)]
+ [InlineData(Autonomy.Gated, ActionRisk.Destructive, true)]
+ [InlineData(Autonomy.Autonomous, ActionRisk.Destructive, true)]
+ public void Gate_matrix(Autonomy autonomy, ActionRisk risk, bool shouldHold) =>
+ Assert.Equal(shouldHold, GatePolicy.ShouldHold(autonomy, risk));
+}
diff --git a/tests/TeamUp.IntegrationTests/ReviewFlowTests.cs b/tests/TeamUp.IntegrationTests/ReviewFlowTests.cs
new file mode 100644
index 0000000..671da79
--- /dev/null
+++ b/tests/TeamUp.IntegrationTests/ReviewFlowTests.cs
@@ -0,0 +1,262 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using TeamUp.Modules.Assembler.Queue;
+using TeamUp.Modules.Assembler.Runtime;
+using TeamUp.SharedKernel.Access;
+using TeamUp.SharedKernel.Ai;
+using Xunit;
+
+namespace TeamUp.IntegrationTests;
+
+///
+/// M5 acceptance: Aria (gated) proposes a spec → it waits in the owner's review inbox with its
+/// trace → the owner edits and approves → the artifact lands and four child tasks appear on the
+/// board → edit distance is recorded. Plus: a Member cannot approve; an Autonomous agent's draft
+/// executes without review; destructive ALWAYS holds; send-back works.
+///
+public sealed class ReviewFlowTests(PostgresFixture postgres) : IClassFixture
+{
+ private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
+
+ private sealed record AuthResponse(string Token, Guid MemberId);
+
+ private sealed record InviteResponse(Guid InvitationId, string Token);
+
+ private sealed record IdResponse(Guid Id);
+
+ private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
+
+ private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
+
+ private sealed record SyncResult(int Indexed);
+
+ private sealed record RunResponse(
+ Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status,
+ string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error);
+
+ private sealed record ReviewItemResponse(
+ Guid Id, Guid OrganizationId, Guid TeamId, Guid AgentRunId, Guid AgentId, Guid WorkItemId,
+ string ActionKind, string Risk, string Title, string Content, List ChildTitles,
+ string? Trace, string Status, string? Decision, double? EditDistance, DateTimeOffset CreatedAtUtc);
+
+ private sealed record TaskResponse(
+ Guid Id, Guid TeamId, string Title, string? Description, string Type,
+ string Status, string AssigneeKind, Guid? AssigneeId, Guid? ParentId);
+
+ private sealed record BoardColumn(string Status, List Items);
+
+ private sealed record BoardResponse(Guid TeamId, List Columns);
+
+ private sealed record AuditEntryResponse(
+ Guid Id, string Action, string EntityType, Guid EntityId,
+ Guid? ActorMemberId, string? Details, DateTimeOffset OccurredAtUtc);
+
+ [Fact]
+ public async Task Gated_run_holds_for_review_then_edit_and_approve_lands_on_the_board()
+ {
+ var settings = new Dictionary
+ {
+ ["GitSource:Provider"] = "filesystem",
+ ["GitSource:Root"] = LocateSkillsDirectory(),
+ };
+
+ await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
+ using var anon = factory.CreateClient();
+
+ // --- Setup: owner, org, team, stub BYOK config, skills, Aria (gated) on a seat ---
+ var owner = await PostOk(anon, "/api/identity/bootstrap", new
+ {
+ organizationName = "AliaSaaS",
+ ownerEmail = "owner@alia.test",
+ ownerDisplayName = "Owner",
+ ownerPassword = "Passw0rd!",
+ });
+ using var client = Authed(factory, owner.Token);
+
+ await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
+ var team = await PostOk(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
+ var config = await PostOk(client, "/api/integrations/api-configs", new
+ {
+ organizationId = owner.OrganizationId,
+ name = "Vertex-Pro",
+ provider = "stub",
+ model = "gemini-pro",
+ apiKey = "sk-demo-key",
+ });
+ await PostOk(client, "/api/skills/sync", new { });
+
+ var seat = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Product Owner" });
+ await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new
+ {
+ name = "Aria",
+ monogram = "AR",
+ autonomy = "Gated",
+ apiConfigId = config.Id,
+ skillKeys = new[] { "spec-writing", "story-breakdown" },
+ docs = Array.Empty(),
+ });
+
+ var task = await PostOk(client, "/api/orgboard/tasks", new
+ {
+ teamId = team.Id,
+ title = "Add a logout button to the header",
+ description = "Users need a way to end their session.",
+ type = "Spec",
+ });
+
+ // --- Run Aria against the task; the worker drains it ---
+ var run = await PostOk(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
+ await DrainOneJob(factory);
+ var done = await client.GetFromJsonAsync($"/api/assembler/runs/{run.Id}");
+ Assert.Equal("Completed", done!.Status);
+
+ // --- The gated draft is HELD: it waits in the review inbox with its trace ---
+ var pending = await client.GetFromJsonAsync>(
+ $"/api/governance/reviews?organizationId={owner.OrganizationId}");
+ var held = Assert.Single(pending!);
+ Assert.Equal("Pending", held.Status);
+ Assert.Equal("write-spec", held.ActionKind);
+ Assert.Equal("Draft", held.Risk);
+ Assert.Equal(task.Id, held.WorkItemId);
+ Assert.False(string.IsNullOrWhiteSpace(held.Trace)); // the expandable reasoning trace
+
+ // --- A plain Member cannot approve (owner/team-owner capability) ---
+ var invite = await PostOk(client, "/api/identity/invitations", new
+ {
+ email = "dev@alia.test",
+ scopeType = "Organization",
+ scopeId = owner.OrganizationId,
+ role = "Member",
+ organizationId = owner.OrganizationId,
+ });
+ var member = await PostOk(anon, "/api/identity/invitations/accept",
+ new { token = invite.Token, displayName = "Dev", password = "Passw0rd!" });
+ using (var memberClient = Authed(factory, member.Token))
+ {
+ var forbidden = await memberClient.PostAsJsonAsync($"/api/governance/reviews/{held.Id}/approve", new { });
+ Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
+ }
+
+ // --- Owner edits and approves: final spec + four child stories ---
+ string[] children =
+ [
+ "Add a logout button to the header (authenticated only)",
+ "Clear the session and redirect to /login on click",
+ "Hide the logout button when signed out",
+ "Add a regression test for protected routes after logout",
+ ];
+ var approved = await PostOk(client, $"/api/governance/reviews/{held.Id}/approve", new
+ {
+ content = "Spec: a logout button in the header that ends the session and returns to sign-in.",
+ childTitles = children,
+ });
+ Assert.Equal("Approved", approved.Status);
+ Assert.Equal("EditedAndApproved", approved.Decision);
+ Assert.NotNull(approved.EditDistance);
+ Assert.True(approved.EditDistance > 0, "editing before approval must record a positive edit distance");
+
+ // --- The artifact + four child tasks landed on the board ---
+ var board = await client.GetFromJsonAsync($"/api/orgboard/board?teamId={team.Id}");
+ var all = board!.Columns.SelectMany(c => c.Items).ToList();
+ var parent = all.Single(i => i.Id == task.Id);
+ Assert.Contains("Spec: a logout button", parent.Description);
+ var childTasks = all.Where(i => i.ParentId == task.Id).ToList();
+ Assert.Equal(4, childTasks.Count);
+ Assert.All(childTasks, c => Assert.Equal("Story", c.Type));
+
+ // --- Deciding twice is rejected ---
+ var again = await client.PostAsJsonAsync($"/api/governance/reviews/{held.Id}/approve", new { });
+ Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
+
+ // --- Audit recorded the hold and the edited approval (with the metric) ---
+ var audit = await client.GetFromJsonAsync>(
+ $"/api/governance/audit?organizationId={owner.OrganizationId}&take=200");
+ Assert.Contains(audit!, e => e.Action == "action.held" && e.EntityId == held.Id);
+ Assert.Contains(audit!, e => e.Action == "review.edited-approved"
+ && e.EntityId == held.Id && (e.Details ?? "").Contains("editDistance="));
+
+ // --- An Autonomous agent's draft executes without review ---
+ var seatQa = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "QA" });
+ await client.PostAsJsonAsync($"/api/orgboard/seats/{seatQa.Id}/agent", new
+ {
+ name = "Quill",
+ monogram = "QU",
+ autonomy = "Autonomous",
+ apiConfigId = config.Id,
+ skillKeys = new[] { "test-plan-generation", "diff-review" },
+ docs = Array.Empty(),
+ });
+ var qaTask = await PostOk(client, "/api/orgboard/tasks", new
+ {
+ teamId = team.Id,
+ title = "QA the logout flow",
+ description = (string?)null,
+ type = "Test",
+ });
+ await PostOk(client, "/api/assembler/runs", new { seatId = seatQa.Id, workItemId = qaTask.Id });
+ await DrainOneJob(factory);
+
+ var pendingAfter = await client.GetFromJsonAsync>(
+ $"/api/governance/reviews?organizationId={owner.OrganizationId}");
+ Assert.Empty(pendingAfter!); // nothing new held
+
+ var boardAfter = await client.GetFromJsonAsync($"/api/orgboard/board?teamId={team.Id}");
+ var qaParent = boardAfter!.Columns.SelectMany(c => c.Items).Single(i => i.Id == qaTask.Id);
+ Assert.Contains("[stub", qaParent.Description); // the artifact executed straight onto the task
+
+ // --- Destructive ALWAYS holds, even for an Autonomous seat — and send-back works ---
+ await using (var scope = factory.Services.CreateAsyncScope())
+ {
+ var gate = scope.ServiceProvider.GetRequiredService();
+ var result = await gate.EvaluateAsync(new AgentActionProposal(
+ Guid.NewGuid(), seatQa.Id, seatQa.AgentId ?? Guid.NewGuid(), qaTask.Id,
+ team.Id, owner.OrganizationId, Autonomy.Autonomous,
+ "delete-branch", "Destructive", "Delete the release branch", "rm -rf …", [], null));
+ Assert.Equal(GateOutcome.Held, result.Outcome);
+ Assert.NotNull(result.ReviewItemId);
+
+ var sentBack = await PostOk(
+ client, $"/api/governance/reviews/{result.ReviewItemId}/sendback", new { });
+ Assert.Equal("SentBack", sentBack.Status);
+ }
+ }
+
+ private static async Task DrainOneJob(TeamUpWebFactory factory)
+ {
+ await using var scope = factory.Services.CreateAsyncScope();
+ var queue = scope.ServiceProvider.GetRequiredService();
+ var job = await queue.ClaimNextAsync("test-worker");
+ Assert.NotNull(job);
+ await scope.ServiceProvider.GetRequiredService().ProcessAsync(job!);
+ }
+
+ private static HttpClient Authed(TeamUpWebFactory factory, string token)
+ {
+ var client = factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ return client;
+ }
+
+ private static async Task PostOk(HttpClient client, string url, object body)
+ {
+ var response = await client.PostAsJsonAsync(url, body);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var value = await response.Content.ReadFromJsonAsync();
+ Assert.NotNull(value);
+ return value!;
+ }
+
+ private static string LocateSkillsDirectory()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
+ {
+ dir = dir.Parent;
+ }
+
+ Assert.NotNull(dir);
+ return Path.Combine(dir!.FullName, "skills");
+ }
+}