Skip to content

Commit 2d4701b

Browse files
authored
Merge pull request #1033 from Chris0Jeky/paper/1020-side-effects
Add proposal side-effect analysis with 7-category breakdown and reversibility window
2 parents f063754 + 73582d9 commit 2d4701b

13 files changed

Lines changed: 1317 additions & 0 deletions

File tree

backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,19 @@ public class AutomationProposalsController : AuthenticatedControllerBase
2727
private readonly IAutomationProposalService _proposalService;
2828
private readonly IAutomationExecutorService _executorService;
2929
private readonly BoardAuthorizationService _authorizationService;
30+
private readonly ISideEffectAnalyzer _sideEffectAnalyzer;
3031

3132
public AutomationProposalsController(
3233
IAutomationProposalService proposalService,
3334
IAutomationExecutorService executorService,
3435
BoardAuthorizationService authorizationService,
36+
ISideEffectAnalyzer sideEffectAnalyzer,
3537
IUserContext userContext) : base(userContext)
3638
{
3739
_proposalService = proposalService;
3840
_executorService = executorService;
3941
_authorizationService = authorizationService;
42+
_sideEffectAnalyzer = sideEffectAnalyzer;
4043
}
4144

4245
/// <summary>
@@ -261,6 +264,24 @@ public async Task<IActionResult> DismissProposals(
261264
: result.ToErrorActionResult();
262265
}
263266

267+
/// <summary>
268+
/// Gets the side-effect analysis for a proposal, including the 7-category breakdown
269+
/// and reversibility posture.
270+
/// </summary>
271+
[HttpGet("{id}/side-effects")]
272+
public async Task<IActionResult> GetProposalSideEffects(Guid id, CancellationToken cancellationToken = default)
273+
{
274+
if (!TryGetCurrentUserId(out var callerUserId, out var errorResult))
275+
return errorResult!;
276+
277+
var auth = await AuthorizeProposalAsync(id, callerUserId, requireWriteAccess: false, cancellationToken);
278+
if (auth.ErrorResult is not null)
279+
return auth.ErrorResult;
280+
281+
var result = await _sideEffectAnalyzer.AnalyzeAsync(id, cancellationToken);
282+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
283+
}
284+
264285
/// <summary>
265286
/// Gets a diff preview for a proposal showing what changes will be made.
266287
/// </summary>

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
4747
services.AddScoped<HistoryService>();
4848
services.AddScoped<IHistoryService>(sp => sp.GetRequiredService<HistoryService>());
4949
services.AddScoped<IAutomationProposalService, AutomationProposalService>();
50+
services.AddScoped<ISideEffectAnalyzer, SideEffectAnalyzer>();
5051
services.AddScoped<IProposalRevisionService, ProposalRevisionService>();
5152
services.AddScoped<IAutomationPolicyEngine, AutomationPolicyEngine>();
5253
services.AddScoped<IAutomationPlannerService, AutomationPlannerService>();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
public record SideEffectRowDto(
4+
string Key,
5+
string Value,
6+
string Tone // "active" | "passive"
7+
);
8+
9+
public record ReversibilityDto(
10+
string Summary,
11+
string Description,
12+
long WindowMs
13+
);
14+
15+
public record ProposalSideEffectsDto(
16+
IReadOnlyList<SideEffectRowDto> Rows,
17+
ReversibilityDto Reversibility
18+
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
/// <summary>
7+
/// Analyzes a proposal's operations to produce a 7-category side-effect breakdown
8+
/// and a reversibility posture.
9+
/// </summary>
10+
public interface ISideEffectAnalyzer
11+
{
12+
/// <summary>
13+
/// Analyzes the side effects of the specified proposal.
14+
/// </summary>
15+
Task<Result<ProposalSideEffectsDto>> AnalyzeAsync(Guid proposalId, CancellationToken cancellationToken = default);
16+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Application.Interfaces;
3+
using Taskdeck.Domain.Common;
4+
using Taskdeck.Domain.Entities;
5+
using Taskdeck.Domain.Exceptions;
6+
7+
namespace Taskdeck.Application.Services;
8+
9+
/// <summary>
10+
/// Analyzes a proposal's operations to produce a 7-category side-effect breakdown
11+
/// (Cards, Subtasks, Comments, Activity log, Notifications, Webhooks, Calendar)
12+
/// and a reversibility posture.
13+
/// </summary>
14+
public sealed class SideEffectAnalyzer : ISideEffectAnalyzer
15+
{
16+
// Action types that actively mutate cards
17+
private static readonly HashSet<string> CardMutatingActions = new(StringComparer.OrdinalIgnoreCase)
18+
{
19+
"create", "move", "archive", "update", "delete", "bulk_move"
20+
};
21+
22+
private readonly IUnitOfWork _unitOfWork;
23+
24+
public SideEffectAnalyzer(IUnitOfWork unitOfWork)
25+
{
26+
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
27+
}
28+
29+
public async Task<Result<ProposalSideEffectsDto>> AnalyzeAsync(
30+
Guid proposalId,
31+
CancellationToken cancellationToken = default)
32+
{
33+
var proposal = await _unitOfWork.AutomationProposals.GetByIdAsync(proposalId, cancellationToken);
34+
if (proposal is null)
35+
return Result.Failure<ProposalSideEffectsDto>(ErrorCodes.NotFound, "Proposal not found.");
36+
37+
var operations = proposal.Operations;
38+
39+
// Determine webhook status for the board
40+
bool hasActiveWebhooks = false;
41+
if (proposal.BoardId.HasValue)
42+
{
43+
var webhookSubs = await _unitOfWork.OutboundWebhookSubscriptions
44+
.GetActiveByBoardAsync(proposal.BoardId.Value, cancellationToken);
45+
hasActiveWebhooks = webhookSubs.Count > 0;
46+
}
47+
48+
var rows = BuildSideEffectRows(operations, hasActiveWebhooks);
49+
var reversibility = ComputeReversibility(operations, proposal.RiskLevel);
50+
51+
var dto = new ProposalSideEffectsDto(
52+
Rows: rows.Select(r => new SideEffectRowDto(r.Key, r.Value, r.Tone.ToString().ToLowerInvariant())).ToList(),
53+
Reversibility: new ReversibilityDto(reversibility.Summary, reversibility.Description, reversibility.WindowMs));
54+
55+
return Result.Success(dto);
56+
}
57+
58+
internal static IReadOnlyList<SideEffectRow> BuildSideEffectRows(
59+
IReadOnlyList<AutomationProposalOperation> operations,
60+
bool hasActiveWebhooks)
61+
{
62+
bool hasCardMutation = operations.Any(op =>
63+
CardMutatingActions.Contains(op.ActionType) &&
64+
string.Equals(op.TargetType, "card", StringComparison.OrdinalIgnoreCase));
65+
bool hasColumnMutation = operations.Any(op =>
66+
string.Equals(op.TargetType, "column", StringComparison.OrdinalIgnoreCase));
67+
bool hasBoardMutation = hasCardMutation || hasColumnMutation;
68+
bool hasAnyOperation = operations.Count > 0;
69+
70+
return new List<SideEffectRow>
71+
{
72+
new(
73+
"Cards",
74+
hasBoardMutation
75+
? hasCardMutation && hasColumnMutation
76+
? "Creates, moves, or archives cards and adds columns on the board"
77+
: hasCardMutation
78+
? "Creates, moves, or archives cards on the board"
79+
: "Adds columns to the board (no direct card mutations)"
80+
: "No board mutations",
81+
hasBoardMutation ? SideEffectTone.Active : SideEffectTone.Passive),
82+
83+
new(
84+
"Subtasks",
85+
"Subtask management not yet supported",
86+
SideEffectTone.Passive),
87+
88+
new(
89+
"Comments",
90+
"Proposals do not create comments",
91+
SideEffectTone.Passive),
92+
93+
new(
94+
"Activity log",
95+
hasAnyOperation
96+
? "Audit entries will be recorded for all applied operations"
97+
: "No operations to log",
98+
hasAnyOperation ? SideEffectTone.Active : SideEffectTone.Passive),
99+
100+
new(
101+
"Notifications",
102+
hasAnyOperation
103+
? "Approval or rejection generates notifications"
104+
: "No notifications generated",
105+
hasAnyOperation ? SideEffectTone.Active : SideEffectTone.Passive),
106+
107+
new(
108+
"Webhooks",
109+
hasActiveWebhooks && hasAnyOperation
110+
? "Outbound webhooks configured for this board will fire"
111+
: hasActiveWebhooks
112+
? "Outbound webhooks configured but no operations to trigger them"
113+
: "No outbound webhooks configured",
114+
hasActiveWebhooks && hasAnyOperation ? SideEffectTone.Active : SideEffectTone.Passive),
115+
116+
new(
117+
"Calendar",
118+
"Calendar integration not yet available",
119+
SideEffectTone.Passive)
120+
};
121+
}
122+
123+
internal static Reversibility ComputeReversibility(
124+
IReadOnlyList<AutomationProposalOperation> operations,
125+
RiskLevel riskLevel)
126+
{
127+
// Base window is 6 hours
128+
long windowMs = Reversibility.DefaultWindowMs;
129+
130+
// Adjust window based on risk level
131+
string summary;
132+
string description;
133+
134+
switch (riskLevel)
135+
{
136+
case RiskLevel.Critical:
137+
windowMs = Reversibility.DefaultWindowMs / 2; // 3 hours -- tighter for critical
138+
summary = "3 hours · manual intervention required";
139+
description = "Critical-risk operations may require manual intervention to reverse. " +
140+
"Archive and delete operations can be recovered within the window, " +
141+
"but downstream effects (webhooks, notifications) cannot be recalled.";
142+
break;
143+
144+
case RiskLevel.High:
145+
windowMs = Reversibility.DefaultWindowMs; // 6 hours
146+
summary = "6 hours · single keystroke";
147+
description = "High-risk operations can be reversed within the window. " +
148+
"Card moves and updates are fully reversible; " +
149+
"archived cards can be restored from the archive.";
150+
break;
151+
152+
case RiskLevel.Medium:
153+
windowMs = Reversibility.DefaultWindowMs; // 6 hours
154+
summary = "6 hours · single keystroke";
155+
description = "Medium-risk operations are fully reversible within the window. " +
156+
"All board mutations can be undone from the activity log.";
157+
break;
158+
159+
case RiskLevel.Low:
160+
default:
161+
windowMs = Reversibility.DefaultWindowMs; // 6 hours
162+
summary = "6 hours · single keystroke";
163+
description = "Low-risk operations are fully reversible within the window. " +
164+
"All board mutations can be undone from the activity log.";
165+
break;
166+
}
167+
168+
// If there are no operations, the proposal is a no-op
169+
if (operations.Count == 0)
170+
{
171+
summary = "6 hours · no operations";
172+
description = "This proposal contains no operations and will have no effect.";
173+
windowMs = Reversibility.DefaultWindowMs;
174+
}
175+
176+
return new Reversibility(summary, description, windowMs);
177+
}
178+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
namespace Taskdeck.Domain.Entities;
2+
3+
/// <summary>
4+
/// Value object aggregating the full side-effect analysis for a proposal:
5+
/// the 7-row breakdown and the reversibility posture.
6+
/// </summary>
7+
public sealed class ProposalSideEffects : IEquatable<ProposalSideEffects>
8+
{
9+
/// <summary>The 7-category side-effect breakdown.</summary>
10+
public IReadOnlyList<SideEffectRow> Rows { get; }
11+
12+
/// <summary>The reversibility posture for this proposal.</summary>
13+
public Reversibility Reversibility { get; }
14+
15+
public ProposalSideEffects(IReadOnlyList<SideEffectRow> rows, Reversibility reversibility)
16+
{
17+
if (rows is null || rows.Count == 0)
18+
throw new ArgumentException("Side-effect rows cannot be null or empty.", nameof(rows));
19+
Reversibility = reversibility ?? throw new ArgumentNullException(nameof(reversibility));
20+
21+
Rows = rows;
22+
}
23+
24+
public bool Equals(ProposalSideEffects? other)
25+
{
26+
if (other is null) return false;
27+
if (ReferenceEquals(this, other)) return true;
28+
if (Rows.Count != other.Rows.Count) return false;
29+
for (int i = 0; i < Rows.Count; i++)
30+
{
31+
if (!Rows[i].Equals(other.Rows[i])) return false;
32+
}
33+
return Reversibility.Equals(other.Reversibility);
34+
}
35+
36+
public override bool Equals(object? obj) => Equals(obj as ProposalSideEffects);
37+
38+
public override int GetHashCode()
39+
{
40+
var hash = new HashCode();
41+
foreach (var row in Rows)
42+
hash.Add(row);
43+
hash.Add(Reversibility);
44+
return hash.ToHashCode();
45+
}
46+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
namespace Taskdeck.Domain.Entities;
2+
3+
/// <summary>
4+
/// Value object representing the reversibility window for a proposal.
5+
/// Describes how long a user has to undo the effects and the effort required.
6+
/// </summary>
7+
public sealed class Reversibility : IEquatable<Reversibility>
8+
{
9+
/// <summary>Default reversibility window: 6 hours in milliseconds.</summary>
10+
public const long DefaultWindowMs = 6L * 60 * 60 * 1000; // 21_600_000
11+
12+
/// <summary>Short summary (e.g. "6 hours - single keystroke").</summary>
13+
public string Summary { get; }
14+
15+
/// <summary>Detailed description of the reversibility posture.</summary>
16+
public string Description { get; }
17+
18+
/// <summary>Reversibility window in milliseconds.</summary>
19+
public long WindowMs { get; }
20+
21+
public Reversibility(string summary, string description, long windowMs)
22+
{
23+
if (string.IsNullOrWhiteSpace(summary))
24+
throw new ArgumentException("Reversibility summary cannot be empty.", nameof(summary));
25+
if (string.IsNullOrWhiteSpace(description))
26+
throw new ArgumentException("Reversibility description cannot be empty.", nameof(description));
27+
if (windowMs <= 0)
28+
throw new ArgumentOutOfRangeException(nameof(windowMs), "Reversibility window must be positive.");
29+
30+
Summary = summary;
31+
Description = description;
32+
WindowMs = windowMs;
33+
}
34+
35+
public bool Equals(Reversibility? other)
36+
{
37+
if (other is null) return false;
38+
if (ReferenceEquals(this, other)) return true;
39+
return Summary == other.Summary && Description == other.Description && WindowMs == other.WindowMs;
40+
}
41+
42+
public override bool Equals(object? obj) => Equals(obj as Reversibility);
43+
44+
public override int GetHashCode() => HashCode.Combine(Summary, Description, WindowMs);
45+
46+
public override string ToString() => $"{Summary} ({WindowMs}ms)";
47+
}

0 commit comments

Comments
 (0)