Files
GmRelayBot/docs/superpowers/plans/2026-05-30-completed-game-portfolio.md
T

46 KiB

Completed Game Portfolio Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add moderated public portfolios of completed adventures with multi-session grouping, uploaded covers, GM-profile and club visibility, and participant-submitted reviews.

Architecture: Add a bounded portfolio vertical slice in GmRelay.Web: IPortfolioStore/PortfolioService own PostgreSQL persistence, AuthorizedPortfolioService owns current-user checks and orchestration, and IPortfolioCoverStorage isolates local volume storage from a future S3 implementation. Existing /showcase recruitment queries remain unchanged. Public Razor pages consume sanitized DTOs only.

Tech Stack: .NET 10, Blazor Server, PostgreSQL, Npgsql, Dapper, DbUp SQL migrations, xUnit, Docker Compose.


File Map

Create

  • src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql
  • src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs
  • src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs
  • src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs
  • src/GmRelay.Web/Services/Portfolio/PortfolioService.cs
  • src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs
  • src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs
  • src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs
  • src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs
  • src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs
  • src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor
  • src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor
  • src/GmRelay.Web/Components/Pages/PortfolioEditor.razor
  • src/GmRelay.Web/Components/Pages/PublicPortfolio.razor
  • tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs
  • tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs
  • tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs
  • tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs
  • tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs
  • tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs
  • tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs
  • tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs

Modify

  • src/GmRelay.Web/Program.cs
  • src/GmRelay.Web/appsettings.Development.json
  • src/GmRelay.Web/Dockerfile
  • src/GmRelay.Web/Components/Pages/GroupDetails.razor
  • src/GmRelay.Web/Components/Pages/SessionHistory.razor
  • src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor
  • src/GmRelay.Web/Components/Pages/PublicClub.razor
  • src/GmRelay.Web/wwwroot/app.css
  • .env.example
  • compose.yaml
  • README.md
  • docs/c4-system-context.md
  • Directory.Build.props
  • .gitea/workflows/deploy.yml
  • src/GmRelay.Web/Components/Layout/NavMenu.razor

Task 1: Add Portfolio Schema

Files:

  • Create: tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs

  • Create: src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql

  • Step 1: Write the failing migration source-contract test

Add tests that read V029__add_completed_game_portfolios_and_reviews.sql and assert:

[Fact]
public async Task MigrationV029_ShouldCreatePortfolioTablesAndPublicationGuards()
{
    var migration = await ReadRepositoryFileAsync(
        "src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");

    Assert.Contains("CREATE TABLE portfolio_games", migration, StringComparison.Ordinal);
    Assert.Contains("CREATE TABLE portfolio_game_sessions", migration, StringComparison.Ordinal);
    Assert.Contains("CREATE TABLE portfolio_game_masters", migration, StringComparison.Ordinal);
    Assert.Contains("CREATE TABLE portfolio_game_reviews", migration, StringComparison.Ordinal);
    Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal);
    Assert.Contains("UNIQUE (session_id)", migration, StringComparison.Ordinal);
    Assert.Contains("UNIQUE (portfolio_game_id, author_player_id)", migration, StringComparison.Ordinal);
    Assert.Contains("'Pending', 'Approved', 'Rejected', 'Hidden'", migration, StringComparison.Ordinal);
    Assert.Contains("publication_consent_at", migration, StringComparison.Ordinal);
    Assert.Contains("ix_portfolio_games_public", migration, StringComparison.Ordinal);
    Assert.Contains("ix_portfolio_game_reviews_public", migration, StringComparison.Ordinal);
}

Add a second test asserting the public-card columns are provider-neutral:

[Fact]
public async Task MigrationV029_ShouldStoreProviderNeutralCoverKeys()
{
    var migration = await ReadRepositoryFileAsync(
        "src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql");

    Assert.Contains("cover_storage_key", migration, StringComparison.Ordinal);
    Assert.DoesNotContain("s3_bucket", migration, StringComparison.OrdinalIgnoreCase);
    Assert.DoesNotContain("physical_path", migration, StringComparison.OrdinalIgnoreCase);
}
  • Step 2: Run the migration test to verify RED

Run:

dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioMigrationTests"

Expected: FAIL because V029__add_completed_game_portfolios_and_reviews.sql does not exist.

  • Step 3: Add migration V029

Create the migration with these exact tables and indexes:

CREATE TABLE portfolio_games (
    id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    group_id          UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
    public_slug       VARCHAR(160),
    title             VARCHAR(255) NOT NULL,
    description       TEXT,
    cover_storage_key TEXT,
    system            VARCHAR(50),
    format            VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
    completed_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    is_public         BOOLEAN NOT NULL DEFAULT false,
    published_at      TIMESTAMPTZ,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (NOT is_public OR (
        public_slug IS NOT NULL
        AND description IS NOT NULL
        AND cover_storage_key IS NOT NULL
        AND published_at IS NOT NULL
    ))
);

CREATE UNIQUE INDEX ux_portfolio_games_public_slug
    ON portfolio_games (lower(public_slug))
    WHERE public_slug IS NOT NULL;

CREATE INDEX ix_portfolio_games_group
    ON portfolio_games (group_id, completed_at DESC);

CREATE INDEX ix_portfolio_games_public
    ON portfolio_games (completed_at DESC)
    WHERE is_public = true;

CREATE TABLE portfolio_game_sessions (
    portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
    session_id        UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    PRIMARY KEY (portfolio_game_id, session_id),
    UNIQUE (session_id)
);

CREATE TABLE portfolio_game_masters (
    portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
    player_id         UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
    PRIMARY KEY (portfolio_game_id, player_id)
);

CREATE INDEX ix_portfolio_game_masters_player
    ON portfolio_game_masters (player_id, portfolio_game_id);

CREATE TABLE portfolio_game_reviews (
    id                     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    portfolio_game_id      UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
    author_player_id       UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
    author_display_name    VARCHAR(255) NOT NULL,
    body                   TEXT NOT NULL,
    publication_consent_at TIMESTAMPTZ NOT NULL,
    moderation_status      VARCHAR(20) NOT NULL DEFAULT 'Pending'
        CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
    moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
    moderated_at           TIMESTAMPTZ,
    created_at             TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at             TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (portfolio_game_id, author_player_id)
);

CREATE INDEX ix_portfolio_game_reviews_public
    ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
    WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;

CREATE INDEX ix_portfolio_game_reviews_pending
    ON portfolio_game_reviews (created_at)
    WHERE moderation_status = 'Pending';
  • Step 4: Run the migration tests to verify GREEN

Run the Task 1 command again. Expected: PASS.

  • Step 5: Commit
git add src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql tests/GmRelay.Bot.Tests/Web/PortfolioMigrationTests.cs
git commit -m "feat(data): add completed game portfolio schema"

Task 2: Define Portfolio Contracts And Validation

Files:

  • Create: src/GmRelay.Web/Services/Portfolio/PortfolioContracts.cs

  • Create: src/GmRelay.Web/Services/Portfolio/IPortfolioStore.cs

  • Create: src/GmRelay.Web/Services/Portfolio/PortfolioValidation.cs

  • Create: tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs

  • Create: tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs

  • Step 1: Write failing privacy and validation tests

Add reflection tests that assert PublicPortfolioCard, PublicPortfolioGame, PublicPortfolioMaster, and PublicPortfolioReview do not expose names containing:

var forbidden = new[]
{
    "Id", "External", "Telegram", "Discord", "Moderator",
    "StorageKey", "PhysicalPath", "JoinLink", "Session"
};

Add validation tests:

[Theory]
[InlineData(" Dragon Heist ", "dragon-heist")]
[InlineData("dragon_heist", "dragon-heist")]
public void NormalizeSlug_ShouldReturnCanonicalSlug(string input, string expected)
{
    Assert.Equal(expected, PortfolioValidation.NormalizeSlug(input));
}

[Theory]
[InlineData("")]
[InlineData("ab")]
[InlineData("spaces are fine after normalization but this slug is intentionally far too long to be accepted because it exceeds the maximum portfolio slug size of one hundred and sixty characters")]
[InlineData("кириллица")]
public void NormalizeSlug_ShouldRejectInvalidSlug(string input)
{
    Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeSlug(input));
}

[Theory]
[InlineData("")]
[InlineData("  ")]
public void NormalizeReviewBody_ShouldRejectBlankText(string body)
{
    Assert.Throws<InvalidOperationException>(() => PortfolioValidation.NormalizeReviewBody(body));
}
  • Step 2: Run Task 2 tests to verify RED
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioContractsTests|FullyQualifiedName~PortfolioValidationTests"

Expected: FAIL because the portfolio contracts and validation helper do not exist.

  • Step 3: Add contracts and interface

Define these public sanitized records in PortfolioContracts.cs:

public sealed record PublicPortfolioCard(
    string Slug,
    string Title,
    string CoverPath,
    string? System,
    string? Format,
    DateTime CompletedAt);

public sealed record PublicPortfolioMaster(string Slug, string DisplayName);

public sealed record PublicPortfolioReview(
    string AuthorDisplayName,
    string Body,
    DateTime CreatedAt);

public sealed record PublicPortfolioGame(
    string Slug,
    string Title,
    string Description,
    string CoverPath,
    string? System,
    string? Format,
    DateTime CompletedAt,
    string? ClubName,
    string? ClubSlug,
    IReadOnlyList<PublicPortfolioMaster> Masters,
    IReadOnlyList<PublicPortfolioReview> Reviews);

Define protected records with IDs for editing:

public sealed record PortfolioGameSummary(
    Guid Id, Guid GroupId, string Title, string? PublicSlug, bool IsPublic,
    DateTime CompletedAt, int SessionCount, int MasterCount, int PendingReviewCount);

public sealed record PortfolioSessionOption(
    Guid Id, string Title, DateTime ScheduledAt, bool Selected);

public sealed record PortfolioMasterOption(
    Guid PlayerId, string DisplayName, bool Selected);

public sealed record PortfolioReviewForModeration(
    Guid Id, string AuthorDisplayName, string Body, string ModerationStatus, DateTime CreatedAt);

public sealed record PortfolioGameEditor(
    Guid Id, Guid GroupId, string Title, string? PublicSlug, string? Description,
    string? CoverPath, string? System, string? Format, DateTime CompletedAt, bool IsPublic,
    IReadOnlyList<PortfolioSessionOption> Sessions,
    IReadOnlyList<PortfolioMasterOption> Masters,
    IReadOnlyList<PortfolioReviewForModeration> Reviews);

public sealed record PortfolioGameUpdate(
    string Title, string? PublicSlug, string? Description, string? System, string? Format,
    IReadOnlyCollection<Guid> SessionIds, IReadOnlyCollection<Guid> MasterPlayerIds);

public enum PortfolioReviewSubmissionState
{
    RequiresAuthentication,
    Ineligible,
    Eligible,
    AlreadySubmitted
}

Define IPortfolioStore with:

Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForMasterAsync(string masterSlug);
Task<IReadOnlyList<PublicPortfolioCard>> GetPublicPortfolioGamesForClubAsync(string clubSlug);
Task<PublicPortfolioGame?> GetPublicPortfolioGameBySlugAsync(string slug);
Task<IReadOnlyList<PortfolioGameSummary>> GetPortfolioGamesForGroupAsync(Guid groupId);
Task<Guid?> GetPortfolioGameGroupIdAsync(Guid portfolioGameId);
Task<PortfolioGameEditor?> GetPortfolioGameForManagementAsync(Guid portfolioGameId);
Task<IReadOnlyList<PortfolioSessionOption>> GetEligibleCompletedSessionsAsync(Guid groupId, Guid? portfolioGameId);
Task<IReadOnlyList<PortfolioMasterOption>> GetPortfolioMasterOptionsAsync(Guid groupId, Guid? portfolioGameId);
Task<Guid> CreatePortfolioDraftAsync(Guid groupId, Guid? preselectedSessionId);
Task UpdatePortfolioDraftAsync(Guid portfolioGameId, Guid groupId, PortfolioGameUpdate update);
Task<string?> SetPortfolioCoverAsync(Guid portfolioGameId, Guid groupId, string storageKey);
Task<string?> DeletePortfolioGameAsync(Guid portfolioGameId, Guid groupId);
Task SetPortfolioPublicationAsync(Guid portfolioGameId, Guid groupId, bool isPublic);
Task ModeratePortfolioReviewAsync(Guid reviewId, Guid portfolioGameId, Guid groupId, Guid moderatorPlayerId, string moderationStatus);
Task<PortfolioReviewSubmissionState> GetReviewSubmissionStateAsync(string slug, string platform, string externalUserId);
Task SubmitPortfolioReviewAsync(string slug, string platform, string externalUserId, string displayName, string body);
  • Step 4: Add validation helper

Implement:

public static string NormalizeSlug(string? value)

Rules: trim, lowercase invariant, replace spaces and underscores with -, collapse repeated -, trim -, require length 3..160, require regex ^[a-z0-9]+(?:-[a-z0-9]+)*$.

Implement:

public static string NormalizeTitle(string? value)

Rules: trim, require length 2..255.

Implement:

public static string? NormalizeDescription(string? value)

Rules: null for whitespace, otherwise trim, maximum 5000.

Implement:

public static string NormalizeReviewBody(string? value)

Rules: trim, require length 10..2000.

Implement:

public static string? NormalizeFormat(string? value)

Rules: null for whitespace; otherwise accept only Online, Offline, Hybrid.

  • Step 5: Run Task 2 tests to verify GREEN

Run the Task 2 command again. Expected: PASS.

  • Step 6: Commit
git add src/GmRelay.Web/Services/Portfolio tests/GmRelay.Bot.Tests/Web/PortfolioContractsTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioValidationTests.cs
git commit -m "feat(web): define portfolio contracts and validation"

Task 3: Add Local Cover Storage Behind An S3-Ready Interface

Files:

  • Create: src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs

  • Create: src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs

  • Create: src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs

  • Create: src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs

  • Create: tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs

  • Create: tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs

  • Modify: src/GmRelay.Web/Program.cs

  • Modify: src/GmRelay.Web/appsettings.Development.json

  • Modify: src/GmRelay.Web/Dockerfile

  • Modify: .env.example

  • Modify: compose.yaml

  • Step 1: Write failing storage tests

Cover these cases with a temporary directory:

[Fact]
public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey()
{
    var storage = CreateStorage();
    await using var stream = new MemoryStream(
        [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]);

    var result = await storage.SaveAsync(stream, "image/png");

    Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal);
    Assert.StartsWith("/portfolio-covers/", storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal);
    Assert.True(File.Exists(Path.Combine(storagePath, result.StorageKey)));
}

[Theory]
[InlineData("image/jpeg")]
[InlineData("image/png")]
[InlineData("image/webp")]
public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType)
{
    var storage = CreateStorage();
    await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]);

    await Assert.ThrowsAsync<InvalidOperationException>(
        () => storage.SaveAsync(stream, contentType));
}

Also test a stream larger than LocalPortfolioCoverStorage.MaxBytes, invalid delete keys such as ../escape.png, valid delete, JPEG signature, and WebP RIFF....WEBP signature.

Add source-contract wiring tests:

Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal);
Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal);
Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal);
Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal);
Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal);
  • Step 2: Run storage tests to verify RED
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~LocalPortfolioCoverStorageTests|FullyQualifiedName~PortfolioCoverRuntimeWiringTests"

Expected: FAIL because storage types do not exist.

  • Step 3: Implement cover storage

Define:

public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType);

public interface IPortfolioCoverStorage
{
    Task<PortfolioCoverUploadResult> SaveAsync(Stream content, string contentType, CancellationToken cancellationToken = default);
    Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default);
    string GetPublicPath(string storageKey);
}

public sealed class PortfolioCoverStorageOptions
{
    public const string SectionName = "PortfolioCovers";
    public string StoragePath { get; set; } = "";
}

Implement LocalPortfolioCoverStorage with:

  • public const long MaxBytes = 5 * 1024 * 1024;
  • normalized extensions .jpg, .png, .webp;
  • signature checks: JPEG FF D8 FF, PNG 89 50 4E 47 0D 0A 1A 0A, WebP RIFF plus WEBP;
  • generated key $"{Guid.NewGuid():N}{extension}";
  • safe key regex ^[a-f0-9]{32}\.(jpg|png|webp)$;
  • temporary file write, validation before final File.Move;
  • cleanup of the temporary file in finally;
  • public path /portfolio-covers/{Uri.EscapeDataString(storageKey)}.

In PortfolioCoverStorageExtensions.cs, add:

public static IServiceCollection AddPortfolioCoverStorage(
    this IServiceCollection services,
    IConfiguration configuration)

public static WebApplication UsePortfolioCoverFiles(this WebApplication app)

AddPortfolioCoverStorage configures PortfolioCoverStorageOptions and registers IPortfolioCoverStorage. UsePortfolioCoverFiles resolves relative paths against app.Environment.ContentRootPath, creates the directory, and attaches UseStaticFiles with PhysicalFileProvider, request path /portfolio-covers, known image extensions only, and immutable cache headers.

  • Step 4: Register configuration, static delivery, and Docker volume

In Program.cs:

builder.Services.AddPortfolioCoverStorage(builder.Configuration);

After security headers and before authentication, add:

app.UsePortfolioCoverFiles();

In development settings add:

"PortfolioCovers": {
  "StoragePath": "../../artifacts/portfolio-covers"
}

In compose.yaml, mount:

- "PortfolioCovers__StoragePath=/app/portfolio-covers"

and:

- portfolio_covers:/app/portfolio-covers

Declare:

portfolio_covers:
  name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}

Document PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers in .env.example.

In src/GmRelay.Web/Dockerfile, create and chown both runtime directories before USER $APP_UID:

RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \
    && chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers
  • Step 5: Run storage tests and build to verify GREEN
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~LocalPortfolioCoverStorageTests|FullyQualifiedName~PortfolioCoverRuntimeWiringTests"
dotnet build src/GmRelay.Web/GmRelay.Web.csproj

Expected: PASS and build succeeds with zero warnings.

  • Step 6: Commit
git add src/GmRelay.Web/Services/Portfolio/Covers src/GmRelay.Web/Program.cs src/GmRelay.Web/appsettings.Development.json src/GmRelay.Web/Dockerfile .env.example compose.yaml tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs
git commit -m "feat(web): add local portfolio cover storage"

Task 4: Implement Portfolio Persistence

Files:

  • Create: src/GmRelay.Web/Services/Portfolio/PortfolioService.cs

  • Create: tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs

  • Modify: src/GmRelay.Web/Program.cs

  • Step 1: Write failing SQL source-contract tests

Assert that PortfolioService.cs contains:

Assert.Contains("portfolio_games", source, StringComparison.Ordinal);
Assert.Contains("portfolio_game_sessions", source, StringComparison.Ordinal);
Assert.Contains("portfolio_game_masters", source, StringComparison.Ordinal);
Assert.Contains("portfolio_game_reviews", source, StringComparison.Ordinal);
Assert.Contains("moderation_status = 'Approved'", source, StringComparison.Ordinal);
Assert.Contains("publication_consent_at IS NOT NULL", source, StringComparison.Ordinal);
Assert.Contains("s.scheduled_at < now()", source, StringComparison.Ordinal);
Assert.Contains("FOR UPDATE", source, StringComparison.Ordinal);
Assert.Contains("ON CONFLICT (portfolio_game_id, author_player_id) DO NOTHING", source, StringComparison.Ordinal);

Add scoped assertions against the public-master query:

Assert.Contains("portfolio_game_masters", publicMasterQuery, StringComparison.Ordinal);
Assert.DoesNotContain("public_schedule_enabled = true", publicMasterQuery, StringComparison.Ordinal);

Add scoped assertions against the public-club query:

Assert.Contains("g.public_schedule_enabled = true", publicClubQuery, StringComparison.Ordinal);

Add a regression assertion by reading SessionService.cs:

Assert.Contains("s.scheduled_at > now() - interval '4 hours'", showcaseQuery, StringComparison.Ordinal);
  • Step 2: Run source-contract tests to verify RED
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioServiceSourceTests"

Expected: FAIL because PortfolioService.cs does not exist.

  • Step 3: Implement public reads

Create PortfolioService(NpgsqlDataSource dataSource, IPortfolioCoverStorage coverStorage) : IPortfolioStore.

Implement:

GetPublicPortfolioGamesForMasterAsync(string masterSlug)
GetPublicPortfolioGamesForClubAsync(string clubSlug)
GetPublicPortfolioGameBySlugAsync(string slug)

Rules:

  • Filter portfolio_games.is_public = true.

  • Master query joins portfolio_game_masters and public master_profiles by slug but does not require game_groups.public_schedule_enabled.

  • Club query joins game_groups and requires public_schedule_enabled = true plus public club slug.

  • Detail query returns club name and slug only when the club page is public.

  • Detail query loads selected public masters separately.

  • Detail query loads only consented reviews with moderation_status = 'Approved'.

  • Convert cover_storage_key to a public URL with coverStorage.GetPublicPath.

  • Public DTOs never carry private UUIDs.

  • Step 4: Implement protected reads and writes

Implement:

GetPortfolioGamesForGroupAsync
GetPortfolioGameGroupIdAsync
GetPortfolioGameForManagementAsync
GetEligibleCompletedSessionsAsync
GetPortfolioMasterOptionsAsync
CreatePortfolioDraftAsync
UpdatePortfolioDraftAsync
SetPortfolioCoverAsync
DeletePortfolioGameAsync
SetPortfolioPublicationAsync
ModeratePortfolioReviewAsync

Rules:

  • Draft creation optionally links one session only if it belongs to the same group, is in the past, and is not linked elsewhere.

  • Update runs in one transaction, locks the portfolio row, updates scalar fields, replaces child links, rejects cross-club or future sessions, and accepts only managers from the same club.

  • Cover replacement returns the prior storage key after the database update.

  • Delete returns the cover key after deleting the row.

  • Publishing locks the row and verifies slug, description, cover key, one or more linked past sessions, and one or more masters before setting is_public = true and published_at = COALESCE(published_at, now()).

  • Unpublishing only sets is_public = false.

  • Moderation accepts Approved, Rejected, or Hidden, stores moderator ID and timestamp, and scopes the review to the managed adventure.

  • Step 5: Implement authenticated review methods

Implement:

GetReviewSubmissionStateAsync
SubmitPortfolioReviewAsync

Rules:

  • Resolve linked player identities using the same player_links direction as SessionService.ResolveEffectivePlayerIdAsync.

  • Eligible means the public adventure has at least one linked past session with a matching session_participants.player_id, sp.is_gm = false, and sp.registration_status = 'Active'.

  • Existing review returns AlreadySubmitted.

  • Missing eligible participation returns Ineligible.

  • Insert starts with Pending, stores trimmed text and the display-name snapshot, and uses ON CONFLICT ... DO NOTHING to reject duplicates.

  • Step 6: Register portfolio store

In Program.cs add:

builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
  • Step 7: Run tests and build to verify GREEN
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioServiceSourceTests"
dotnet build src/GmRelay.Web/GmRelay.Web.csproj

Expected: PASS and build succeeds with zero warnings.

  • Step 8: Commit
git add src/GmRelay.Web/Services/Portfolio/PortfolioService.cs src/GmRelay.Web/Program.cs tests/GmRelay.Bot.Tests/Web/PortfolioServiceSourceTests.cs
git commit -m "feat(web): add portfolio persistence"

Task 5: Add Authorized Portfolio Orchestration

Files:

  • Create: src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs

  • Create: tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs

  • Modify: src/GmRelay.Web/Program.cs

  • Step 1: Write failing authorization tests

Use small fake implementations of IPortfolioStore, ISessionStore, and IPortfolioCoverStorage.

Cover:

[Fact]
public async Task CreateDraftForCurrentUserAsync_ShouldAllowCoGm()
{
    var service = CreateService(isManager: true);
    var created = await service.CreateDraftForCurrentUserAsync(groupId, sessionId);
    Assert.Equal(draftId, created);
}

[Fact]
public async Task CreateDraftForCurrentUserAsync_ShouldRejectAnotherClubManager()
{
    var service = CreateService(isManager: false);
    await Assert.ThrowsAsync<SessionAccessDeniedException>(
        () => service.CreateDraftForCurrentUserAsync(groupId, null));
}

[Fact]
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteOldCoverAfterSuccessfulSwap()
{
    var service = CreateService(isManager: true, oldStorageKey: "old.png");
    await service.ReplaceCoverForCurrentUserAsync(portfolioGameId, content, "image/png");
    Assert.Contains("old.png", fakeStorage.DeletedKeys);
}

[Fact]
public async Task ReplaceCoverForCurrentUserAsync_ShouldDeleteNewCoverWhenPersistenceFails()
{
    var service = CreateService(isManager: true, throwOnSetCover: true);
    await Assert.ThrowsAsync<InvalidOperationException>(
        () => service.ReplaceCoverForCurrentUserAsync(portfolioGameId, content, "image/png"));
    Assert.Contains("new.png", fakeStorage.DeletedKeys);
}

Also test: unauthorized editor read, unauthorized update, unauthorized moderation, delete cleanup, anonymous review state, review body normalization, slug normalization, publication call, and moderator effective-player resolution.

  • Step 2: Run authorization tests to verify RED
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~AuthorizedPortfolioServiceTests"

Expected: FAIL because AuthorizedPortfolioService does not exist.

  • Step 3: Implement authorized wrapper

Create:

public sealed class AuthorizedPortfolioService(
    IPortfolioStore portfolioStore,
    ISessionStore sessionStore,
    IPortfolioCoverStorage coverStorage,
    IHttpContextAccessor httpContextAccessor)

Implement management methods:

GetPortfolioGamesForCurrentUserAsync(Guid groupId)
GetPortfolioGameForCurrentUserAsync(Guid portfolioGameId)
GetCompletedSessionsForCurrentUserAsync(Guid groupId)
CreateDraftForCurrentUserAsync(Guid groupId, Guid? preselectedSessionId)
UpdateDraftForCurrentUserAsync(Guid portfolioGameId, PortfolioGameUpdate update)
ReplaceCoverForCurrentUserAsync(Guid portfolioGameId, Stream content, string contentType, CancellationToken cancellationToken = default)
DeleteForCurrentUserAsync(Guid portfolioGameId)
SetPublicationForCurrentUserAsync(Guid portfolioGameId, bool isPublic)
ModerateReviewForCurrentUserAsync(Guid portfolioGameId, Guid reviewId, string moderationStatus)

Implement review methods:

GetReviewSubmissionStateForCurrentUserAsync(string slug)
SubmitReviewForCurrentUserAsync(string slug, string body, bool publicationConsent)

Rules:

  • Every management method checks ISessionStore.IsGroupManagerAsync.

  • GetCompletedSessionsForCurrentUserAsync returns IPortfolioStore.GetEligibleCompletedSessionsAsync(groupId, null) only after the same manager check.

  • Resolve the owning group through GetPortfolioGameGroupIdAsync before loading private editor data or applying any ID-scoped mutation.

  • UpdateDraftForCurrentUserAsync applies PortfolioValidation to title, slug, description, and format.

  • Reject review submission unless the consent checkbox is true.

  • Cover replacement stores the new cover first, updates the database second, deletes the old cover only after the swap, and cleans up the new cover when persistence fails.

  • Delete removes the database row first and deletes the cover second.

  • Step 4: Register scoped service

In Program.cs:

builder.Services.AddScoped<AuthorizedPortfolioService>();
  • Step 5: Run tests and build to verify GREEN
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~AuthorizedPortfolioServiceTests"
dotnet build src/GmRelay.Web/GmRelay.Web.csproj

Expected: PASS and build succeeds with zero warnings.

  • Step 6: Commit
git add src/GmRelay.Web/Services/Portfolio/AuthorizedPortfolioService.cs src/GmRelay.Web/Program.cs tests/GmRelay.Bot.Tests/Web/AuthorizedPortfolioServiceTests.cs
git commit -m "feat(web): authorize portfolio management and reviews"

Task 6: Add Protected Portfolio Management UI

Files:

  • Create: src/GmRelay.Web/Components/Pages/PortfolioEditor.razor

  • Create: src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor

  • Create: tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs

  • Modify: src/GmRelay.Web/Components/Pages/GroupDetails.razor

  • Modify: src/GmRelay.Web/Components/Pages/SessionHistory.razor

  • Modify: src/GmRelay.Web/wwwroot/app.css

  • Step 1: Write failing protected-page source tests

Assert:

Assert.Contains("@page \"/portfolio/manage/{PortfolioGameId:guid}\"", editor, StringComparison.Ordinal);
Assert.Contains("@attribute [Authorize]", editor, StringComparison.Ordinal);
Assert.Contains("InputFile", editor, StringComparison.Ordinal);
Assert.Contains("ReplaceCoverForCurrentUserAsync", editor, StringComparison.Ordinal);
Assert.Contains("SetPublicationForCurrentUserAsync", editor, StringComparison.Ordinal);
Assert.Contains("ModerateReviewForCurrentUserAsync", editor, StringComparison.Ordinal);
Assert.Contains("CreateDraftForCurrentUserAsync", groupDetails, StringComparison.Ordinal);
Assert.Contains("@page \"/group/{GroupId:guid}/completed\"", completedSessions, StringComparison.Ordinal);
Assert.Contains("@attribute [Authorize]", completedSessions, StringComparison.Ordinal);
Assert.Contains("GetCompletedSessionsForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
Assert.Contains("CreateDraftForCurrentUserAsync", completedSessions, StringComparison.Ordinal);
Assert.Contains("CreateDraftForCurrentUserAsync", sessionHistory, StringComparison.Ordinal);
Assert.Contains("Добавить в портфолио", sessionHistory, StringComparison.Ordinal);
  • Step 2: Run page tests to verify RED
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests"

Expected: FAIL because protected portfolio UI is absent.

  • Step 3: Extend group management page

Inject AuthorizedPortfolioService. Load summaries after the existing group authorization succeeds. Add a section with:

  • heading Проведённые приключения;

  • create button calling CreateDraftForCurrentUserAsync(GroupId, null) and navigating to /portfolio/manage/{id};

  • link to /group/{GroupId}/completed;

  • rows for title, draft/public badge, linked-session count, GM count, pending-review count, and edit link.

  • Step 4: Add completed-session list

Create GroupCompletedSessions.razor:

  • authorized route /group/{GroupId:guid}/completed;

  • load rows through GetCompletedSessionsForCurrentUserAsync;

  • show past session title and Moscow date;

  • provide history links;

  • provide Добавить в портфолио buttons calling CreateDraftForCurrentUserAsync(GroupId, session.Id) and navigating to /portfolio/manage/{id};

  • render a compact empty state when the list is empty.

  • Step 5: Add completed-session quick action

In SessionHistory.razor, inject AuthorizedPortfolioService. If the loaded session has ScheduledAt < DateTime.UtcNow, render Добавить в портфолио. On click call:

var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(groupId.Value, SessionId);
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
  • Step 6: Add protected editor

Create PortfolioEditor.razor:

  • authorized route /portfolio/manage/{PortfolioGameId:guid};

  • load editor via GetPortfolioGameForCurrentUserAsync;

  • edit title, slug, description, system, and format;

  • render checkbox lists for completed sessions and GMs;

  • save through UpdateDraftForCurrentUserAsync;

  • upload one IBrowserFile with OpenReadStream(LocalPortfolioCoverStorage.MaxBytes) and ReplaceCoverForCurrentUserAsync;

  • publish/unpublish through SetPublicationForCurrentUserAsync;

  • delete through DeleteForCurrentUserAsync;

  • render moderation rows and buttons Одобрить, Отклонить, Скрыть.

  • Step 7: Add protected UI styles

Add .portfolio-management-list, .portfolio-editor-grid, .portfolio-option-list, .portfolio-review-moderation, and mobile layout rules to app.css.

  • Step 8: Run Task 6 tests and build to verify GREEN
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests"
dotnet build src/GmRelay.Web/GmRelay.Web.csproj

Expected: PASS and build succeeds with zero warnings.

  • Step 9: Commit
git add src/GmRelay.Web/Components/Pages/PortfolioEditor.razor src/GmRelay.Web/Components/Pages/GroupCompletedSessions.razor src/GmRelay.Web/Components/Pages/GroupDetails.razor src/GmRelay.Web/Components/Pages/SessionHistory.razor src/GmRelay.Web/wwwroot/app.css tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs
git commit -m "feat(web): add portfolio management UI"

Task 7: Add Public Portfolio Pages And Review Form

Files:

  • Create: src/GmRelay.Web/Components/Portfolio/PortfolioCardGrid.razor

  • Create: src/GmRelay.Web/Components/Pages/PublicPortfolio.razor

  • Modify: src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor

  • Modify: src/GmRelay.Web/Components/Pages/PublicClub.razor

  • Modify: src/GmRelay.Web/wwwroot/app.css

  • Modify: tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs

  • Step 1: Add failing public-page source tests

Assert:

Assert.Contains("@page \"/portfolio/{Slug}\"", publicPortfolio, StringComparison.Ordinal);
Assert.Contains("@layout PublicLayout", publicPortfolio, StringComparison.Ordinal);
Assert.DoesNotContain("@attribute [Authorize]", publicPortfolio, StringComparison.Ordinal);
Assert.Contains("GetPublicPortfolioGameBySlugAsync", publicPortfolio, StringComparison.Ordinal);
Assert.Contains("SubmitReviewForCurrentUserAsync", publicPortfolio, StringComparison.Ordinal);
Assert.Contains("publicationConsent", publicPortfolio, StringComparison.Ordinal);
Assert.Contains("PortfolioCardGrid", publicMaster, StringComparison.Ordinal);
Assert.Contains("GetPublicPortfolioGamesForMasterAsync", publicMaster, StringComparison.Ordinal);
Assert.Contains("PortfolioCardGrid", publicClub, StringComparison.Ordinal);
Assert.Contains("GetPublicPortfolioGamesForClubAsync", publicClub, StringComparison.Ordinal);
Assert.DoesNotContain("PlayerId", publicPortfolio, StringComparison.Ordinal);
Assert.DoesNotContain("StorageKey", publicPortfolio, StringComparison.Ordinal);
  • Step 2: Run public-page tests to verify RED

Run the Task 6 page-test command. Expected: FAIL on missing public portfolio page and card grid.

  • Step 3: Add reusable public card grid

Create PortfolioCardGrid.razor with parameter:

[Parameter, EditorRequired]
public IReadOnlyList<PublicPortfolioCard> Games { get; set; } = [];

Each card renders cover, title, completion date, optional system/format badges, and /portfolio/{Slug} link.

  • Step 4: Extend public GM and club pages

  • Inject IPortfolioStore.

  • Load master cards with GetPublicPortfolioGamesForMasterAsync(Slug.Trim()).

  • Load club cards with GetPublicPortfolioGamesForClubAsync(Slug.Trim()).

  • Render PortfolioCardGrid below existing upcoming-session content when cards exist.

  • Keep the public club portfolio tied to the existing public-club route; keep GM portfolio independent from club visibility.

  • Step 5: Add public portfolio detail and conditional review form

Create PublicPortfolio.razor:

  • load sanitized detail with GetPublicPortfolioGameBySlugAsync;

  • load current-user submission state through AuthorizedPortfolioService;

  • render cover hero, description, completion date, system, format, optional club link, GM links, and approved reviews;

  • for Eligible, show textarea and required consent checkbox;

  • for AlreadySubmitted, show Отзыв отправлен на модерацию;

  • for Ineligible, show a short non-sensitive explanation;

  • for RequiresAuthentication, show sign-in link;

  • submit through SubmitReviewForCurrentUserAsync.

  • Step 6: Add public styles

Add .portfolio-grid, .portfolio-card, .portfolio-card-cover, .portfolio-cover-hero, .portfolio-review-list, .portfolio-review-card, and responsive rules to app.css.

  • Step 7: Run page tests and build to verify GREEN
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~PortfolioPagesTests"
dotnet build src/GmRelay.Web/GmRelay.Web.csproj

Expected: PASS and build succeeds with zero warnings.

  • Step 8: Commit
git add src/GmRelay.Web/Components/Portfolio src/GmRelay.Web/Components/Pages/PublicPortfolio.razor src/GmRelay.Web/Components/Pages/PublicMasterProfile.razor src/GmRelay.Web/Components/Pages/PublicClub.razor src/GmRelay.Web/wwwroot/app.css tests/GmRelay.Bot.Tests/Web/PortfolioPagesTests.cs
git commit -m "feat(web): publish completed game portfolios"

Task 8: Update Documentation And Release Version

Files:

  • Modify: README.md

  • Modify: docs/c4-system-context.md

  • Modify: Directory.Build.props

  • Modify: compose.yaml

  • Modify: .gitea/workflows/deploy.yml

  • Modify: src/GmRelay.Web/Components/Layout/NavMenu.razor

  • Modify: tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs

  • Step 1: Update version regression test first

Change the expected UI version in CampaignTemplatesNavigationTests.NavMenu_ShouldExposeCurrentProjectVersion from v3.5.1 to:

Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal);
  • Step 2: Run version test to verify RED
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~NavMenu_ShouldExposeCurrentProjectVersion"

Expected: FAIL because NavMenu.razor still contains v3.5.1.

  • Step 3: Synchronize version 3.6.0

Update:

  • Directory.Build.props: <Version>3.6.0</Version>

  • compose.yaml: gmrelay-bot, gmrelay-discord-bot, and gmrelay-web image tags

  • .gitea/workflows/deploy.yml: VERSION: 3.6.0

  • src/GmRelay.Web/Components/Layout/NavMenu.razor: v3.6.0

  • README.md: current version v3.6.0

  • Step 4: Update user-facing documentation

In README.md document:

  • completed adventure portfolios;
  • /portfolio/{slug};
  • participant-submitted moderated reviews;
  • cover uploads stored in portfolio_covers;
  • optional PORTFOLIO_COVERS_VOLUME_NAME.

In docs/c4-system-context.md document:

  • public portfolio pages and player review submission;

  • portfolio tables in PostgreSQL;

  • PortfolioService, AuthorizedPortfolioService, and IPortfolioCoverStorage;

  • persistent portfolio_covers volume and future S3 replacement boundary.

  • Step 5: Run version test to verify GREEN

Run the Task 8 version-test command again. Expected: PASS.

  • Step 6: Commit
git add README.md docs/c4-system-context.md Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs
git commit -m "docs: document portfolio release and bump version to 3.6.0"

Task 9: Verify The Integrated Feature

Files:

  • No source changes unless verification exposes a defect.

  • Step 1: Run the full test suite

dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal

Expected: all tests pass.

  • Step 2: Run the full build
dotnet build

Expected: build succeeds with zero warnings and zero errors.

  • Step 3: Run formatting verification
dotnet format --verify-no-changes --verbosity diagnostic

Expected: exit code 0.

  • Step 4: Check version synchronization
rg -n "3\.5\.1|3\.6\.0" Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor README.md

Expected: release references use 3.6.0; no required release file contains 3.5.1.

  • Step 5: Start the local app and visually inspect with Browser

Run:

dotnet run --project src/GmRelay.AppHost/GmRelay.AppHost.csproj

Use the in-app Browser plugin to inspect:

  • public GM profile portfolio cards;

  • public club portfolio cards;

  • /portfolio/{slug} detail page;

  • eligible review form and consent checkbox;

  • protected editor layout;

  • mobile-width responsive layout.

  • Step 6: Request code review

Dispatch a review subagent focused on:

  • privacy of public DTOs and Razor output;

  • SQL authorization and cross-club boundaries;

  • cover-storage path safety and cleanup;

  • review eligibility and moderation;

  • unchanged /showcase future-session behavior;

  • version synchronization.

  • Step 7: Apply review fixes and repeat verification

Repeat Steps 1-4 after any change.


Execution Order And Ownership

Execute tasks sequentially because later tasks depend on earlier contracts:

  1. Schema
  2. Contracts and validation
  3. Cover storage
  4. Portfolio persistence
  5. Authorized orchestration
  6. Protected UI
  7. Public UI
  8. Documentation and version
  9. Integrated verification

For subagent execution, assign one fresh worker per task. Workers must not revert edits from earlier tasks. Use separate spec-compliance and code-quality review agents after each task as required by superpowers:subagent-driven-development.