M5: action gate + review inbox — edit distance captured for real

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 07:45:35 +03:30
parent b5ce7a31de
commit d83ad87151
20 changed files with 1070 additions and 2 deletions
@@ -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);
}