Skip to content

Subsystem 1a: Discord workspace provisioning & configuration - #4

Merged
HandyS11 merged 29 commits into
developfrom
feat/workspace-provisioning
Jun 14, 2026
Merged

Subsystem 1a: Discord workspace provisioning & configuration#4
HandyS11 merged 29 commits into
developfrom
feat/workspace-provisioning

Conversation

@HandyS11

Copy link
Copy Markdown
Owner

Summary

Implements subsystem 1a — the bot now owns and provisions its own Discord workspace (categories, channels, anchored messages) instead of asking admins to wire them up. This replaces the foundation's /server and /bind commands, which were the wrong model for this product.

What changed

  • Removed /server add|list|remove, /bind, the ChannelBinding entity/service, and their tests. Servers are no longer typed in by hand — they register via a ServerRegisteredEvent (a stubbed dev trigger in 1a; real FCM pairing lands in 1b). RustServer/ServerService are kept.
  • New RustPlusBot.Features.Workspace project built around a declarative desired-state reconciler:
    • A spec registry — each subsystem contributes ChannelSpec/MessageSpec via DI ("grow per subsystem"), so later features just register their channels.
    • An IWorkspaceGateway seam over Discord.Net, so the whole engine is unit-tested against an in-memory fake (no Discord needed).
    • WorkspaceReconciler converges desired (registry) vs actual (stored snowflakes + live Discord) via resolve → adopt → create, serialized per guild by ProvisioningLock, with a bot-permission pre-flight. Idempotent, drift-adopting, and self-healing.
  • Persistence: three Provisioned* tables (cascade FK to RustServer, unique per scope+key) + WorkspaceStore, and a WorkspaceProvisioning migration that drops ChannelBindings.
  • Surfaces: /setup (admin-gated) provisions the global RustPlusBot category (#information/#setup/#settings); a #settings language selector (EN/FR) re-renders the workspace; dev-gated /workspace reset and /workspace simulate-server. i18n seam landed (EN + FR).
  • Hosted service: startup reconcile, ChannelDestroyed self-heal (reset-safe — won't resurrect a workspace cleared by /workspace reset), and a ServerRegisteredEvent consumer that provisions a per-server category + static #info.
  • Host wiring: AddWorkspace, a Workspace:EnableDangerCommands flag (off in prod, on in Development), and an InteractionModuleAssembly seam so feature projects' slash commands get discovered.

49 tests (reconciler idempotency/adopt/self-heal/concurrency/per-server, store, teardown, localizer, renderers, DI composition). Build is 0 warnings / 0 errors under the strict analyzer set (NetAnalyzers/Roslynator/Sonar, TreatWarningsAsErrors).

Test Plan

Automated (in CI): dotnet build RustPlusBot.slnx (0/0) and dotnet test RustPlusBot.slnx (49 passing).

Manual smoke (requires a bot token + invite with Manage Channels/Roles/Messages + Embed Links):

  • /setup creates the RustPlusBot category with #information/#setup/#settings, read-only to members, embeds rendered.
  • Re-running /setup produces no duplicates.
  • Deleting #information → it reappears (self-heal).
  • Switching to Français in #settings re-renders embeds in FR.
  • (Development) /workspace simulate-server name:Test ip:1.1.1.1 port:28015 → a Test category with #info appears.
  • (Development) /workspace reset → confirm → everything is deleted and does not reappear.

🤖 Generated with Claude Code

HandyS11 and others added 28 commits June 14, 2026 21:43
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rkspace provisioning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ueries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… assembly seam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces WorkspaceServiceCollectionExtensions.AddWorkspace() wiring
all workspace singletons and scoped services, validated by a
ValidateScopes=true composition test that proves the full DI graph
resolves without captive-dependency violations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…es, skip empty payloads

- HealGuildAsync now heals whichever scopes are provisioned (global-if-present + each
  provisioned server category) instead of gating on the global category, so a per-server-only
  guild (e.g. simulate-server without /setup) self-heals; still no-ops when nothing is
  provisioned, preserving reset resurrection-safety.
- EnsureMessagesAsync skips posting an empty payload (a renderer whose source entity vanished
  mid-reconcile) since Discord rejects a message with no content/embed/components.
- Document the in-process event bus no-replay window on the ServerRegistered consumer.
Copilot AI review requested due to automatic review settings June 14, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements “Subsystem 1a” by introducing a desired-state Discord workspace provisioning system (categories/channels/anchored messages) and removing the previous admin-wired /server + /bind workflow. This adds a new RustPlusBot.Features.Workspace feature project, a persistence model for provisioned Discord resources, and host wiring so the bot can reconcile and self-heal its workspace.

Changes:

  • Added a workspace spec registry + reconciler (+ teardown) with Discord.Net gateway seam, hosted startup/self-heal behavior, and admin/dev interaction modules (/setup, /workspace ..., language selector).
  • Added EF Core persistence for provisioned categories/channels/messages (and migration) and wired IWorkspaceStore into DI; extended IServerService with GetAsync.
  • Removed legacy channel binding and server-management interaction modules and their persistence/tests; updated docs and solution/project references accordingly.

Reviewed changes

Copilot reviewed 91 out of 92 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs Adds coverage for WorkspaceStore upsert/delete/culture behaviors.
tests/RustPlusBot.Persistence.Tests/Workspace/ProvisioningSchemaTests.cs Verifies cascade delete + global scope persistence in schema.
tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs Updates DI assertions after removing bindings.
tests/RustPlusBot.Persistence.Tests/Bindings/BindingServiceTests.cs Removes tests for deleted binding subsystem.
tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs Validates scoped resolution of reconciler/teardown via DI.
tests/RustPlusBot.Features.Workspace.Tests/Teardown/WorkspaceTeardownServiceTests.cs Covers reset/remove-server teardown flows.
tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj Introduces new test project for workspace feature.
tests/RustPlusBot.Features.Workspace.Tests/Registry/WorkspaceRegistryTests.cs Covers registry aggregation and ordering behavior.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerServerTests.cs Tests per-server provisioning and unknown-server behavior.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerSelfHealTests.cs Tests recreate behavior after external deletion.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerProvisionTests.cs Tests fresh provisioning and permission preflight.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerMessageTests.cs Tests message repost vs edit behavior and orphan retention.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerIdempotencyTests.cs Ensures reconciles are idempotent.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerHealTests.cs Tests healing behavior and reset non-resurrection.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerConcurrencyTests.cs Tests per-guild serialization outcome under concurrency.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/WorkspaceReconcilerAdoptTests.cs Tests adopt path when same-named channel exists.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ReconcilerHarness.cs Adds harness + fakes wiring for reconciler tests.
tests/RustPlusBot.Features.Workspace.Tests/Reconciler/ProvisioningLockTests.cs Tests guild-scoped lock serialization.
tests/RustPlusBot.Features.Workspace.Tests/Placeholder.cs Adds placeholder compilation test for new project.
tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs Tests renderer output (information/settings/server info).
tests/RustPlusBot.Features.Workspace.Tests/Localization/LocalizerTests.cs Tests i18n fallback/normalization/formatting.
tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceStore.cs In-memory workspace store for unit tests.
tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGatewayTests.cs Tests fake gateway round-tripping and deletion.
tests/RustPlusBot.Features.Workspace.Tests/Fakes/FakeWorkspaceGateway.cs In-memory gateway used by reconciler tests.
src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs EF Core implementation of IWorkspaceStore.
src/RustPlusBot.Persistence/Workspace/IWorkspaceStore.cs Defines persistence API for provisioned workspace resources.
src/RustPlusBot.Persistence/Servers/ServerService.cs Adds GetAsync to support server-scoped reconciliation.
src/RustPlusBot.Persistence/Servers/IServerService.cs Adds GetAsync contract for server lookup by guild+id.
src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs Registers IWorkspaceStore; removes binding service registration.
src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs Updates snapshot for provisioning tables and removed bindings.
src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.Designer.cs Adds migration model for workspace provisioning schema.
src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.cs Adds migration creating provisioning tables and dropping bindings.
src/RustPlusBot.Persistence/Configurations/ProvisionedMessageConfiguration.cs Configures EF mapping for ProvisionedMessage.
src/RustPlusBot.Persistence/Configurations/ProvisionedChannelConfiguration.cs Configures EF mapping for ProvisionedChannel.
src/RustPlusBot.Persistence/Configurations/ProvisionedCategoryConfiguration.cs Configures EF mapping for ProvisionedCategory.
src/RustPlusBot.Persistence/Configurations/ChannelBindingConfiguration.cs Removes EF config for deleted ChannelBinding.
src/RustPlusBot.Persistence/BotDbContext.cs Adds provisioning DbSets/configs; removes channel bindings DbSet/config.
src/RustPlusBot.Persistence/Bindings/IBindingService.cs Removes binding service interface.
src/RustPlusBot.Persistence/Bindings/BindingService.cs Removes binding service implementation.
src/RustPlusBot.Host/RustPlusBot.Host.csproj References workspace feature project.
src/RustPlusBot.Host/Program.cs Binds Workspace options and registers workspace feature.
src/RustPlusBot.Host/appsettings.json Adds Workspace:EnableDangerCommands configuration.
src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs DI wiring for workspace feature, gateway, hosted service, modules.
src/RustPlusBot.Features.Workspace/WorkspaceOptions.cs Adds options model for danger-command gating.
src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs Defines stable channel/message keys for persistence.
src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs Implements reset/remove scope deletion under provisioning lock.
src/RustPlusBot.Features.Workspace/Teardown/IWorkspaceTeardownService.cs Defines teardown service contract.
src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs Provides per-server desired-state specs.
src/RustPlusBot.Features.Workspace/Specs/GlobalWorkspaceSpecProvider.cs Provides global desired-state specs.
src/RustPlusBot.Features.Workspace/RustPlusBot.Features.Workspace.csproj Introduces new feature project and dependencies.
src/RustPlusBot.Features.Workspace/Registry/WorkspaceScope.cs Defines global vs per-server scopes.
src/RustPlusBot.Features.Workspace/Registry/WorkspaceRegistry.cs Aggregates contributed specs into ordered views.
src/RustPlusBot.Features.Workspace/Registry/MessageSpec.cs Declarative anchored-message spec.
src/RustPlusBot.Features.Workspace/Registry/MessageRenderContext.cs Context for message rendering.
src/RustPlusBot.Features.Workspace/Registry/IWorkspaceRegistry.cs Interface for aggregated spec registry.
src/RustPlusBot.Features.Workspace/Registry/IMessageSpecProvider.cs DI contribution interface for message specs.
src/RustPlusBot.Features.Workspace/Registry/IMessageRenderer.cs Renderer interface keyed by stable message key.
src/RustPlusBot.Features.Workspace/Registry/IChannelSpecProvider.cs DI contribution interface for channel specs.
src/RustPlusBot.Features.Workspace/Registry/ChannelSpec.cs Declarative channel spec (name key, perms, order).
src/RustPlusBot.Features.Workspace/Registry/ChannelPermissionProfile.cs Permission profile enum for provisioned channels.
src/RustPlusBot.Features.Workspace/Reconciler/WorkspaceReconciler.cs Core reconciler (resolve/adopt/create) and self-heal logic.
src/RustPlusBot.Features.Workspace/Reconciler/ReconcileResult.cs Result model for reconcile outcomes and missing perms.
src/RustPlusBot.Features.Workspace/Reconciler/ProvisioningLock.cs Guild-scoped semaphore lock implementation.
src/RustPlusBot.Features.Workspace/Reconciler/IWorkspaceReconciler.cs Reconciler interface (global/server/heal).
src/RustPlusBot.Features.Workspace/Reconciler/IProvisioningLock.cs Provisioning lock interface.
src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs Adds dev/admin commands (/workspace reset, /workspace simulate-server).
src/RustPlusBot.Features.Workspace/Modules/SetupModule.cs Adds /setup to provision global + per-server workspace.
src/RustPlusBot.Features.Workspace/Modules/SettingsComponentModule.cs Handles language select menu and triggers re-render.
src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs Renders global setup embed.
src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs Renders settings embed + language selector components.
src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs Renders per-server info embed (endpoint).
src/RustPlusBot.Features.Workspace/Messages/InformationMessageRenderer.cs Renders global information embed incl. server count.
src/RustPlusBot.Features.Workspace/Localization/Localizer.cs Implements culture normalization + fallback i18n.
src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs Adds built-in EN/FR localization catalog.
src/RustPlusBot.Features.Workspace/Localization/ILocalizer.cs Localizer interface.
src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs Startup reconcile + channel deletion self-heal + server-registered consumer.
src/RustPlusBot.Features.Workspace/Gateway/MessagePayload.cs Message payload model (text/embed/components).
src/RustPlusBot.Features.Workspace/Gateway/IWorkspaceGateway.cs Gateway interface over Discord primitives.
src/RustPlusBot.Features.Workspace/Gateway/DiscordWorkspaceGateway.cs Discord.Net implementation of gateway operations.
src/RustPlusBot.Features.Workspace/AssemblyInfo.cs Exposes internals to workspace tests.
src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs Adds domain entity for anchored messages.
src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs Adds domain entity for provisioned channels.
src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs Adds domain entity for provisioned categories.
src/RustPlusBot.Domain/Guilds/ChannelBinding.cs Removes legacy binding entity.
src/RustPlusBot.Domain/Guilds/BoundFeature.cs Removes legacy bound-feature enum.
src/RustPlusBot.Discord/Modules/ServerModule.cs Removes legacy /server interaction module.
src/RustPlusBot.Discord/Modules/BindModule.cs Removes legacy /bind interaction module.
src/RustPlusBot.Discord/InteractionModuleAssembly.cs Adds assembly marker for module discovery across feature projects.
src/RustPlusBot.Discord/DiscordBotService.cs Scans additional module assemblies to load feature interactions.
src/RustPlusBot.Abstractions/Events/ServerRegisteredEvent.cs Introduces event used to trigger per-server provisioning.
RustPlusBot.slnx Adds workspace feature + tests projects to solution.
docs/development/running-locally.md Updates local run instructions for /setup + workspace provisioning flow.
Files not reviewed (1)
  • src/RustPlusBot.Persistence/Migrations/20260614195321_WorkspaceProvisioning.Designer.cs: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +50
public bool CategoryExists(ulong guildId, ulong categoryId) => _categories.ContainsKey(categoryId);

public Task<ulong?> FindCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken)
{
var match = _categories.Values.FirstOrDefault(c =>
string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(match is null ? (ulong?)null : match.Id);
}

public Task<ulong> CreateCategoryAsync(ulong guildId, string name, CancellationToken cancellationToken)
{
var id = NextId();
_categories[id] = new Category(id, name);
CreatedCategories++;
return Task.FromResult(id);
}

public bool ChannelExists(ulong guildId, ulong channelId) => _channels.ContainsKey(channelId);

public Task<ulong?> FindChannelAsync(ulong guildId,
ulong categoryId,
string name,
CancellationToken cancellationToken)
{
var match = _channels.Values.FirstOrDefault(c =>
c.CategoryId == categoryId && string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase));
return Task.FromResult(match is null ? (ulong?)null : match.Id);
}
public const string ConfirmResetId = "workspace:reset:confirm";

/// <summary>Prompts to delete the entire provisioned workspace (dev-gated).</summary>
[SlashCommand("reset", "Delete ALL provisioned channels and records for this server (dangerous)")]
Comment thread src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs
return;
}

var culture = selectedValues.Length > 0 ? selectedValues[0] : "en";
Comment thread src/RustPlusBot.Features.Workspace/Messages/SettingsMessageRenderer.cs Outdated
Comment on lines +182 to +184
var adopted = await gateway.FindChannelAsync(guildId, categoryId, name, cancellationToken)
.ConfigureAwait(false);
if (adopted is ulong adoptedId)
…set wording, Français label

- SettingsComponentModule: only persist supported cultures (en/fr) from the (forgeable) component
  payload, defaulting to en, so an overlong/unknown value can't break GuildSettings.Culture (max 16).
- WorkspaceAdminModule: validate simulate-server port is 1-65535; clarify /workspace reset
  description to 'this Discord server' to disambiguate from a Rust server.
- SettingsMessageRenderer: correct the user-facing language label to 'Français'.
@HandyS11

Copy link
Copy Markdown
Owner Author

Thanks @copilot — addressed in 4c25b01:

  • Settings culture validation ✅ — SettingsComponentModule now only persists en/fr from the (forgeable) component payload, defaulting to en. An unexpected/overlong value can no longer hit GuildSettings.Culture (max 16) and fail the handler.
  • /workspace simulate-server port ✅ — now validated to 1–65535 before insert (restores the guard the old /server add had).
  • /workspace reset wording ✅ — description disambiguated to "…in this Discord server…" so it isn't confused with a single Rust server; the confirm prompt already says "every channel and category the bot provisioned here."
  • "Français" label ✅ — corrected.

Two I'm intentionally leaving for now:

  • FakeWorkspaceGateway ignores guildId — test-only double. Discord snowflakes are globally unique, and the fake's ids come from a global counter, so no test shares ids across guilds; guild isolation that matters (the persistence layer) is enforced by WorkspaceStore, which is guild-scoped and tested. Happy to make the fake guild-aware if we later add cross-guild gateway tests.
  • Adoption only matches the current-culture channel name — correct, and an accepted best-effort limitation: adopt-by-name is a safety net for the narrow case where the provisioning records are lost and the culture was changed. Normal drift (records intact) self-heals fine. A full fix means searching every culture's localized name; deferring rather than adding that complexity to 1a.

@HandyS11
HandyS11 merged commit 9fa5252 into develop Jun 14, 2026
1 of 2 checks passed
@HandyS11
HandyS11 deleted the feat/workspace-provisioning branch June 14, 2026 21:23
Copilot stopped work on behalf of HandyS11 due to an error June 14, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants