From d83ad87151f99546357d7d4d8b418ad528d27a72 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 10 Jun 2026 07:45:35 +0330 Subject: [PATCH] =?UTF-8?q?M5:=20action=20gate=20+=20review=20inbox=20?= =?UTF-8?q?=E2=80=94=20edit=20distance=20captured=20for=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedKernel: - ActionRisk (risk lives on the action) + GatePolicy (the pure autonomy x risk matrix: Read never holds, Draft/Publish hold unless Autonomous, Destructive ALWAYS holds). - IActionGate (AgentActionProposal -> execute|hold) and IBoardWriter.AttachArtifactAsync. Governance: - ReviewItem (held action: artifact, child titles, trace, decision, edit distance) in a new review_items table (AddReviewItems migration). - ActionGate: hold -> ReviewItem + "action.held" audit; autonomous -> execute + audit. - HeldActionExecutor: writes the artifact onto the task and creates the child tasks via IBoardWriter (implemented by OrgBoard — no cross-module table access). - Review inbox API: GET /api/governance/reviews (scope-filtered to where the caller may approve), POST /reviews/{id}/approve (optional edited content/children -> normalized edit distance recorded — the north-star metric), POST /reviews/{id}/sendback. Deciding twice is 409; Members are 403. Assembler: - OutputParser (numbered-list child titles, conservative) and the executor now hands every completed run's proposal to the gate. OrgBoard: WorkItem.AttachArtifact + BoardWriter.AttachArtifactAsync. Verified: build green; ArchitectureTests 8/8; IntegrationTests 41/41 incl. the full M5 acceptance — Aria (gated) proposes a spec, it waits in the inbox with its trace, a Member is 403'd, the owner edits-and-approves, the spec + four child stories land on the board, edit distance > 0 is recorded and audited; Quill (autonomous) executes straight to the board; destructive holds even for an autonomous seat and can be sent back. Plus the GatePolicy matrix. Co-Authored-By: Claude Opus 4.8 --- .../Runtime/AgentRunExecutor.cs | 20 +- .../Runtime/OutputParser.cs | 25 ++ .../Domain/ReviewItem.cs | 78 ++++++ .../Endpoints/GovernanceEndpoints.cs | 130 +++++++++ .../Gate/ActionGate.cs | 47 ++++ .../Gate/HeldActionExecutor.cs | 30 ++ .../GovernanceModule.cs | 4 + .../Persistence/GovernanceDbContext.cs | 14 + .../20260610041006_AddReviewItems.Designer.cs | 150 ++++++++++ .../20260610041006_AddReviewItems.cs | 66 +++++ .../GovernanceDbContextModelSnapshot.cs | 81 ++++++ .../Domain/WorkItem.cs | 9 + .../TeamUp.Modules.OrgBoard/OrgBoardModule.cs | 2 + .../Runtime/BoardWriter.cs | 41 +++ .../TeamUp.SharedKernel/Access/ActionRisk.cs | 13 + .../TeamUp.SharedKernel/Access/GatePolicy.cs | 18 ++ .../TeamUp.SharedKernel/Ai/IActionGate.cs | 36 +++ .../TeamUp.SharedKernel/Board/IBoardWriter.cs | 20 ++ .../GatePolicyTests.cs | 26 ++ .../ReviewFlowTests.cs | 262 ++++++++++++++++++ 20 files changed, 1070 insertions(+), 2 deletions(-) create mode 100644 src/Modules/TeamUp.Modules.Assembler/Runtime/OutputParser.cs create mode 100644 src/Modules/TeamUp.Modules.Governance/Domain/ReviewItem.cs create mode 100644 src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs create mode 100644 src/Modules/TeamUp.Modules.Governance/Gate/HeldActionExecutor.cs create mode 100644 src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.Governance/Persistence/Migrations/20260610041006_AddReviewItems.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Runtime/BoardWriter.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Access/ActionRisk.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Access/GatePolicy.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Ai/IActionGate.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Board/IBoardWriter.cs create mode 100644 tests/TeamUp.IntegrationTests/GatePolicyTests.cs create mode 100644 tests/TeamUp.IntegrationTests/ReviewFlowTests.cs 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<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(); +} 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, +} + +/// <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; + } +} 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<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)); + } } 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; + +/// <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); + } +} 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; + +/// <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); + } + } +} 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<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); } 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<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); + }); } } 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 @@ +// <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 + } + } +} 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 +{ + /// <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"); + } + } +} 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 @@ // <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 } } 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; } + + /// <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; + } } 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<OrgBoardDbContext>(options => options.UseNpgsql(connectionString)); services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>()); services.AddScoped<IAgentRunContextProvider, AgentRunContextProvider>(); + services.AddScoped<IBoardWriter, BoardWriter>(); 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; + +/// <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); + } +} 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; + +/// <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, +} 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; + +/// <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, + }; +} 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; + +/// <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); +} 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); + +/// <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); +} 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; + +/// <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)); +} 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; + +/// <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"); + } +}