Merge M5: action gate + review inbox

Governance closes the loop: the autonomy x risk gate (destructive always holds), the
ReviewItem + review inbox (approve / edit-and-approve / send back) with the reasoning
trace, execution of approved actions onto the board (artifact + child tasks), and the
north-star metric — human edit distance — captured and audited for real. Verified:
ArchitectureTests 8/8, IntegrationTests 41/41, client build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 08:53:43 +03:30
24 changed files with 1294 additions and 3 deletions
+2
View File
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import { BoardPage } from '@/pages/BoardPage'
import { LoginPage } from '@/pages/LoginPage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { useAuth } from '@/store/auth'
@@ -14,6 +15,7 @@ export default function App() {
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster richColors position="top-right" />
+2 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react'
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
@@ -28,6 +28,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<nav className="flex flex-1 flex-col gap-1 p-3">
<NavItem icon={LayoutDashboard} label="Board" to="/" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Inbox} label="Cartable" muted />
<NavItem icon={Network} label="Org chart" muted />
</nav>
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+202
View File
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, ShieldAlert } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface ReviewItem {
id: string
teamId: string
agentRunId: string
agentId: string
workItemId: string
actionKind: string
risk: string
title: string
content: string
childTitles: string[]
trace: string | null
status: string
createdAtUtc: string
}
export function ReviewsPage() {
const organizationId = useAuth((s) => s.organizationId)
const [items, setItems] = useState<ReviewItem[] | null>(null)
const load = useCallback(async () => {
if (!organizationId) return
try {
setItems(await api.get<ReviewItem[]>(`/api/governance/reviews?organizationId=${organizationId}`))
} catch (err) {
toast.error((err as Error).message)
setItems([])
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-3xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Review inbox</h1>
<p className="text-sm text-muted-foreground">
Held agent actions awaiting your decision. Edit before approving your edits feed the metric.
</p>
</div>
{items === null && (
<div className="flex flex-col gap-4">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
)}
{items?.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Nothing is waiting on you. Held agent actions will appear here.
</CardContent>
</Card>
)}
<div className="flex flex-col gap-4">
{items?.map((item) => (
<ReviewCard
key={item.id}
item={item}
onDecided={(id) => setItems((s) => s?.filter((x) => x.id !== id) ?? s)}
/>
))}
</div>
</div>
</AppShell>
)
}
function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: string) => void }) {
const [content, setContent] = useState(item.content)
const [childrenText, setChildrenText] = useState(item.childTitles.join('\n'))
const [showTrace, setShowTrace] = useState(false)
const [busy, setBusy] = useState(false)
const destructive = item.risk.toLowerCase() === 'destructive'
async function decide(action: 'approve' | 'sendback') {
setBusy(true)
try {
if (action === 'approve') {
const childTitles = childrenText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const result = await api.post<{ editDistance: number | null; decision: string }>(
`/api/governance/reviews/${item.id}/approve`,
{ content, childTitles },
)
const distance = result.editDistance ?? 0
toast.success(
result.decision === 'EditedAndApproved'
? `Approved with edits — edit distance ${distance.toFixed(3)}`
: 'Approved as proposed',
)
} else {
await api.post(`/api/governance/reviews/${item.id}/sendback`, {})
toast.info('Sent back to the agent')
}
onDecided(item.id)
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-seat-ai font-semibold text-white">
AI
</span>
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base">{item.title}</CardTitle>
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{item.actionKind}</Badge>
<Badge variant={destructive ? 'destructive' : 'outline'}>
{destructive && <ShieldAlert />}
{item.risk}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(item.createdAtUtc).toLocaleString()}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label>
<Textarea
id={`content-${item.id}`}
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className="font-mono text-xs"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`children-${item.id}`}>Child tasks (one per line)</Label>
<Textarea
id={`children-${item.id}`}
value={childrenText}
onChange={(e) => setChildrenText(e.target.value)}
rows={4}
placeholder="No child tasks proposed — add lines to create them on approval."
/>
</div>
<button
type="button"
onClick={() => setShowTrace((v) => !v)}
className="flex items-center gap-1 self-start text-xs font-medium text-primary hover:underline"
>
{showTrace ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
Reasoning trace
</button>
{showTrace && (
<pre className="max-h-48 overflow-auto rounded-lg bg-muted p-3 text-xs">{formatTrace(item.trace)}</pre>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={() => decide('sendback')}>
Send back
</Button>
<Button disabled={busy} onClick={() => decide('approve')}>
{busy ? 'Working…' : 'Approve'}
</Button>
</div>
</CardContent>
</Card>
)
}
function formatTrace(trace: string | null): string {
if (!trace) return 'No trace captured.'
try {
return JSON.stringify(JSON.parse(trace), null, 2)
} catch {
return trace
}
}
@@ -12,7 +12,8 @@ internal sealed record AgentRunPayload(Guid RunId);
/// <summary>
/// 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.
/// </summary>
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<AgentRunExecutor> 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);
}
@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
namespace TeamUp.Modules.Assembler.Runtime;
/// <summary>
/// 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.
/// </summary>
internal static partial class OutputParser
{
private const int MaxChildren = 10;
private const int MaxTitleLength = 300;
[GeneratedRegex(@"^\s*\d{1,2}[\.\)]\s+(?<title>.+?)\s*$", RegexOptions.Multiline)]
private static partial Regex NumberedLine();
public static IReadOnlyList<string> 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();
}
@@ -0,0 +1,78 @@
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.Governance.Domain;
internal enum ReviewStatus
{
Pending,
Approved,
SentBack,
}
/// <summary>
/// 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.
/// </summary>
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<string> 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<string> 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;
}
}
@@ -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<string> ChildTitles,
string? Trace,
string Status,
string? Decision,
double? EditDistance,
DateTimeOffset CreatedAtUtc);
internal sealed record ApproveRequest(string? Content, List<string>? 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<IResult> 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<IResult> ListReviews(
Guid organizationId, string? status, IPermissionService permissions,
GovernanceDbContext db, CancellationToken ct)
{
var wanted = Enum.TryParse<ReviewStatus>(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<IResult> 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<IResult> 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));
}
}
@@ -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;
/// <summary>
/// The action gate: compares the seat's autonomy to the action's risk. Execute now (autonomous +
/// non-destructive) or hold as a <see cref="ReviewItem"/> in the review inbox. Every decision is
/// audited. Destructive always holds — GatePolicy is the backstop.
/// </summary>
internal sealed class ActionGate(
GovernanceDbContext db,
HeldActionExecutor executor,
IAuditLog audit,
TimeProvider clock) : IActionGate
{
public async Task<GateResult> EvaluateAsync(AgentActionProposal proposal, CancellationToken cancellationToken = default)
{
var risk = Enum.TryParse<ActionRisk>(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);
}
}
@@ -0,0 +1,30 @@
using TeamUp.SharedKernel.Board;
namespace TeamUp.Modules.Governance.Gate;
/// <summary>
/// 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.
/// </summary>
internal sealed class HeldActionExecutor(IBoardWriter boardWriter)
{
public async Task ExecuteAsync(
Guid teamId,
Guid workItemId,
string content,
IReadOnlyList<string> 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);
}
}
}
@@ -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<GovernanceDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<GovernanceDbContext>());
services.AddScoped<IAuditLog, AuditLog>();
services.AddScoped<HeldActionExecutor>();
services.AddScoped<IActionGate, ActionGate>();
services.TryAddSingleton(TimeProvider.System);
}
@@ -8,6 +8,7 @@ internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext>
: DbContext(options), IModuleDbContext
{
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
public DbSet<ReviewItem> ReviewItems => Set<ReviewItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -23,5 +24,18 @@ internal sealed class GovernanceDbContext(DbContextOptions<GovernanceDbContext>
entry.HasIndex(a => a.OccurredAtUtc);
entry.HasIndex(a => new { a.EntityType, a.EntityId });
});
modelBuilder.Entity<ReviewItem>(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<string>().HasMaxLength(20);
item.Property(r => r.Decision).HasMaxLength(30);
item.HasIndex(r => new { r.OrganizationId, r.Status });
item.HasIndex(r => r.AgentRunId);
});
}
}
@@ -0,0 +1,150 @@
// <auto-generated />
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
{
/// <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");
});
modelBuilder.Entity("TeamUp.Modules.Governance.Domain.ReviewItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActionKind")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<Guid>("AgentRunId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("ChildTitles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Decision")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<double?>("EditDistance")
.HasColumnType("double precision");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Risk")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Trace")
.HasColumnType("text");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AgentRunId");
b.HasIndex("OrganizationId", "Status");
b.ToTable("review_items", "governance");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Governance.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddReviewItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "review_items",
schema: "governance",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
TeamId = table.Column<Guid>(type: "uuid", nullable: false),
AgentRunId = table.Column<Guid>(type: "uuid", nullable: false),
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
AgentId = table.Column<Guid>(type: "uuid", nullable: false),
WorkItemId = table.Column<Guid>(type: "uuid", nullable: false),
ActionKind = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Risk = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
ChildTitles = table.Column<List<string>>(type: "text[]", nullable: false),
Trace = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Decision = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
EditDistance = table.Column<double>(type: "double precision", nullable: true),
DecidedByMemberId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
DecidedAtUtc = table.Column<DateTimeOffset>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "review_items",
schema: "governance");
}
}
}
@@ -1,5 +1,6 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActionKind")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<Guid>("AgentId")
.HasColumnType("uuid");
b.Property<Guid>("AgentRunId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("ChildTitles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Decision")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<double?>("EditDistance")
.HasColumnType("double precision");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Risk")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Trace")
.HasColumnType("text");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AgentRunId");
b.HasIndex("OrganizationId", "Status");
b.ToTable("review_items", "governance");
});
#pragma warning restore 612, 618
}
}
@@ -61,4 +61,13 @@ internal sealed class WorkItem : Entity
AssigneeId = null;
UpdatedAtUtc = nowUtc;
}
/// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary>
public void AttachArtifact(string content, DateTimeOffset nowUtc)
{
Description = string.IsNullOrWhiteSpace(Description)
? content
: Description + "\n\n---\n\n" + content;
UpdatedAtUtc = nowUtc;
}
}
@@ -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<OrgBoardDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>());
services.AddScoped<IAgentRunContextProvider, AgentRunContextProvider>();
services.AddScoped<IBoardWriter, BoardWriter>();
services.TryAddSingleton(TimeProvider.System);
}
@@ -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;
/// <summary>Writes approved agent actions onto the board — creating child tasks under a parent.</summary>
internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IBoardWriter
{
public async Task<int> CreateChildTasksAsync(
Guid teamId,
Guid parentWorkItemId,
IReadOnlyList<ChildTaskSpec> children,
Guid? createdByMemberId,
CancellationToken cancellationToken = default)
{
var now = clock.GetUtcNow();
var creator = createdByMemberId ?? Guid.Empty;
foreach (var child in children)
{
var type = Enum.TryParse<WorkItemType>(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);
}
}
@@ -0,0 +1,13 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// 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.
/// </summary>
public enum ActionRisk
{
Read,
Draft,
Publish,
Destructive,
}
@@ -0,0 +1,18 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// 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).
/// </summary>
public static class GatePolicy
{
public static bool ShouldHold(Autonomy autonomy, ActionRisk risk) => risk switch
{
ActionRisk.Read => false,
ActionRisk.Destructive => true,
_ => autonomy != Autonomy.Autonomous,
};
}
@@ -0,0 +1,36 @@
using TeamUp.SharedKernel.Access;
namespace TeamUp.SharedKernel.Ai;
/// <summary>An agent's proposed action coming off a completed run, handed to the action gate.</summary>
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<string> ChildTitles,
string? Trace);
public enum GateOutcome
{
Executed,
Held,
}
public sealed record GateResult(GateOutcome Outcome, Guid? ReviewItemId);
/// <summary>
/// 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.
/// </summary>
public interface IActionGate
{
Task<GateResult> EvaluateAsync(AgentActionProposal proposal, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,20 @@
namespace TeamUp.SharedKernel.Board;
public sealed record ChildTaskSpec(string Title, string Type);
/// <summary>
/// Lets Governance execute an approved agent action onto the board (create the child tasks)
/// without referencing OrgBoard's tables. Implemented by OrgBoard.
/// </summary>
public interface IBoardWriter
{
Task<int> CreateChildTasksAsync(
Guid teamId,
Guid parentWorkItemId,
IReadOnlyList<ChildTaskSpec> children,
Guid? createdByMemberId,
CancellationToken cancellationToken = default);
/// <summary>Writes an approved artifact (spec / test plan) onto the work item.</summary>
Task AttachArtifactAsync(Guid workItemId, string content, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,26 @@
using TeamUp.SharedKernel.Access;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>The gate decision matrix — pure policy, no database.</summary>
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));
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class ReviewFlowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
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<string> 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<TaskResponse> Items);
private sealed record BoardResponse(Guid TeamId, List<BoardColumn> 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<string, string?>
{
["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<BootstrapResponse>(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<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk<IdResponse>(client, "/api/integrations/api-configs", new
{
organizationId = owner.OrganizationId,
name = "Vertex-Pro",
provider = "stub",
model = "gemini-pro",
apiKey = "sk-demo-key",
});
await PostOk<SyncResult>(client, "/api/skills/sync", new { });
var seat = await PostOk<SeatResponse>(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<string>(),
});
var task = await PostOk<TaskResponse>(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<RunResponse>(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id });
await DrainOneJob(factory);
var done = await client.GetFromJsonAsync<RunResponse>($"/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<List<ReviewItemResponse>>(
$"/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<InviteResponse>(client, "/api/identity/invitations", new
{
email = "dev@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
var member = await PostOk<AuthResponse>(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<ReviewItemResponse>(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<BoardResponse>($"/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<List<AuditEntryResponse>>(
$"/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<SeatResponse>(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<string>(),
});
var qaTask = await PostOk<TaskResponse>(client, "/api/orgboard/tasks", new
{
teamId = team.Id,
title = "QA the logout flow",
description = (string?)null,
type = "Test",
});
await PostOk<RunResponse>(client, "/api/assembler/runs", new { seatId = seatQa.Id, workItemId = qaTask.Id });
await DrainOneJob(factory);
var pendingAfter = await client.GetFromJsonAsync<List<ReviewItemResponse>>(
$"/api/governance/reviews?organizationId={owner.OrganizationId}");
Assert.Empty(pendingAfter!); // nothing new held
var boardAfter = await client.GetFromJsonAsync<BoardResponse>($"/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<IActionGate>();
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<ReviewItemResponse>(
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<JobQueue>();
var job = await queue.ClaimNextAsync("test-worker");
Assert.NotNull(job);
await scope.ServiceProvider.GetRequiredService<AgentRunExecutor>().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<T> PostOk<T>(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<T>();
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");
}
}