Skip to content

Move message-tags, echo-message, away-notify, multi-prefix and batch onto the capability SPI - #106

Merged
codegeek merged 4 commits into
mainfrom
014-capability-migrations
Oct 24, 2026
Merged

codegeek merged 4 commits into
mainfrom
014-capability-migrations

Conversation

@codegeek

@codegeek codegeek commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Why

#100 added the capability plugin SPI and moved server-time onto it, but core still carried behaviour for the other five capabilities through name checks, contributeTags and includeSenderInFanOut. The modules were markers. This is the second of four PRs for #98 (design: specs/014-capability-plugin-spi/, tasks T017–T041). After it, each capability module owns its behaviour and core keeps only CAP negotiation plus generic mechanisms.

Changes

Migrated one capability at a time, with the listed integration tests green and unchanged at each checkpoint (validation/pr-101/migrations.md):

  • message-tags: inbound client-tag selection, msgid and TAGMSG move into the module. TAGMSG is now an unknown command (421) when message-tags is absent. Forwarded client tags record every capability that selected them and reach only recipients of one of those; tags with no recorded selector are not forwarded.
  • Shared delivery: MessageDelivery is extracted from MessageCommandHandler and serves PRIVMSG, NOTICE and TAGMSG. deliver accepts only the opaque AuthorizedTarget from authorize, so a provider command cannot bypass channel, membership, moderation or ban checks by supplying recipients.
  • echo-message: sender-copy policy through the shared role pipeline.
  • away-notify: core publishes committed join/part/quit/nick/away facts after locks (CommittedEvents); the module builds the AWAY fan-out from them. Core keeps away state and confirmations.
  • multi-prefix: NAMES/WHO/JOIN/SAJOIN render status through generic member facts (MemberFacts, MemberReplies). The module declares its own 2-byte reply bound, replacing the fixed widths in ConfigurationLoader/HostmaskReplyBudget.
  • batch: SessionWriter.enqueueBatch becomes enqueueGroup with validated framed/plain alternatives admitted as one queue entry. A framed group whose wrappers exceed SendQ overflows that recipient instead of falling back to plain.
  • Removed: contributeTags, includeSenderInFanOut, the temporary bridge and the core TagmsgCommandHandler. README documents the API migration (unreleased 0.1.0-SNAPSHOT).
  • Docs: the 001 and 011 contracts describe module-owned behaviour, in a separate commit. Runtime lifecycle ([Bug] Disabling a capability at runtime leaves it active for clients that negotiated it #97) and fault isolation ([Feature] Isolate capability plugin faults and measure the runtime lifecycle #102) are not claimed.

Deviations from tasks.md are listed with reasons in validation/pr-101/results.md.

Testing

  • ./gradlew build: 1,254 tests pass, Spotless/SpotBugs/PMD clean. Aggregate coverage 95.56% line / 88.61% branch (94.95% / 87.30% after [Feature] Capability plugin SPI foundation, with server-time as the first module #100).
  • New integration tests CapabilityGroupTest and CapabilityProviderAbsenceTest. No existing integration test was modified or deleted.
  • ./gradlew loadTest on main and this branch, interleaved: all 8 pass, differences within noise (results.md).
  • irctest (T039) with the frozen harness from [Feature] Capability plugin SPI foundation, with server-time as the first module #100: main and this branch both 315 passed / 155 skipped / 4 failed, with no case-level differences. The 4 failures are CapTestCase requests for multi-prefix, which the frozen controller config does not enable (validation/pr-101/irctest.md).
  • Local SonarCloud branch analysis: gate passed, 0 open issues after fixing 16 test-code issues.
  • Independent review by Codex (GPT-6.1-Sol), 3 rounds: four P2s and one P3 found and fixed, none left (validation/pr-101/review.md).

Fixes #101

🤖 Generated with Claude Code

Summary by Sourcery

Migrate the remaining bundled capabilities onto the typed capability SPI while leaving core responsible only for negotiation and generic delivery, event, reply, and queue mechanisms.

New Features:

  • Move message-tags, echo-message, away-notify, multi-prefix, and batch behavior into capability modules using the capability SPI.
  • Add generic committed-event publishing, authorized message delivery, member reply rendering, and finite output-group support for capability providers.
  • Make TAGMSG provider-owned and unavailable when message-tags is absent.

Bug Fixes:

  • Prevent capability providers from bypassing message target authorization and channel send gates.
  • Restrict forwarded client tags to recipients that negotiated a capability that selected them.
  • Ensure framed output groups are admitted atomically without orphaned openers or closers.

Enhancements:

  • Remove legacy capability behavior hooks and capability-specific branching from core.
  • Replace fixed reply-width assumptions with provider-declared reply budgets.
  • Document the migrated SPI, ownership model, and API replacements.

Documentation:

  • Update protocol, capability ownership, and API migration documentation to reflect module-owned behavior and the remaining scope of the SPI work.

Tests:

  • Add unit, composition, provider-absence, socket integration, load, and compatibility coverage for the migrated capabilities and generic SPI mechanisms.

Chores:

  • Remove the temporary capability bridge and obsolete core TAGMSG handler.

codegeek and others added 2 commits October 23, 2026 16:25
…onto the capability SPI

Core still carried behaviour for five capabilities through name checks,
contributeTags and includeSenderInFanOut, so a capability module was a
marker rather than the owner of its behaviour. Each module now registers
SPI hooks and core keeps only CAP negotiation plus generic mechanisms:

- MessageDelivery is shared by PRIVMSG, NOTICE and TAGMSG. deliver only
  accepts an opaque authorization from authorize, so a caller cannot
  bypass send gates by supplying its own recipients.
- Forwarded client tags record every capability that selected them and
  reach only recipients of one of those; unowned tags fail closed.
- TAGMSG moves into message-tags (421 when it is absent).
- Committed join/part/quit/nick/away facts are published after locks;
  away-notify builds its fan-out from them.
- multi-prefix renders member status through generic member facts and
  declares its own reply bound.
- batch shapes output groups; SessionWriter.enqueueGroup admits a group
  whole, and a framed group that exceeds SendQ overflows instead of
  falling back to plain.

Existing integration tests are unchanged; irctest shows no case-level
difference from main.

Fixes #101

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The data model and protocol contracts still described capability
behaviour as core's. Point them at the owning modules and the generic
reply and output-group rules, without claiming the runtime lifecycle
(#97) or fault isolation (#102) that later PRs add.

Refs #101

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR completes the migration of message-tags, echo-message, away-notify, multi-prefix, and batch—alongside server-time—onto the typed capability SPI, extracting generic delivery, committed-event, reply-rendering, and atomic group-queue mechanisms from core while deleting legacy capability branches and documenting and testing module-owned behavior.

Sequence diagram for authorized message delivery with capability hooks

sequenceDiagram
    participant Sender
    participant Handler as MessageCommandHandler
    participant Delivery as MessageDelivery
    participant SPI as CapabilitySPI
    participant Recipient

    Sender->>Handler: handle
    Handler->>Delivery: authorize
    Delivery-->>Handler: AuthorizedTarget
    Handler->>Delivery: deliver
    Delivery->>SPI: inboundSelection
    SPI-->>Delivery: forwarded tags and owners
    Delivery->>SPI: recipientRoles
    SPI-->>Delivery: delivery roles
    Delivery->>Recipient: enqueue envelope
Loading

Sequence diagram for committed away-notify events

sequenceDiagram
    participant User
    participant Away as AwayCommandHandler
    participant Events as CommittedEvents
    participant SPI as away-notify provider
    participant Peers

    User->>Away: handle
    Away->>Away: setAwayReason
    Away->>User: AWAY confirmation
    Away->>Events: awayChanged
    Events->>SPI: event
    SPI-->>Events: EventIntent
    Events->>Peers: deliver AWAY notice
Loading

Flow diagram for provider-owned output grouping

flowchart LR
    Producer[Server output group]
    Writer[SessionWriter.enqueueGroup]
    Renderer[CapabilityTagRenderer.frame]
    Batch[batch provider]
    Queue[Atomic queue entry]
    Recipient[Recipient connection]

    Producer --> Writer
    Writer --> Renderer
    Renderer --> Batch
    Batch -->|validated framed plan| Queue
    Renderer -->|no applicable provider| Queue
    Queue --> Recipient

    Queue -->|SendQ overflow| Recipient
Loading

File-Level Changes

Change Details Files
Migrates all six bundled capabilities from core name-based behavior to typed capability SPI registrations, so modules own their protocol behavior while core retains negotiation and generic infrastructure.
  • Adds module registrations for message-tag selection/msgid/TAGMSG, sender-copy roles, away event handling, multi-prefix rendering and reply bounds, and batch framing.
  • Removes legacy capability hooks, core TAGMSG handling, capability-specific fan-out checks, and fixed reply-width assumptions.
  • Adds provider-absence and composition coverage proving behavior and CAP offers disappear together when modules are unavailable.
jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java
jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java
jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java
jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java
jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java
jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java
jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java
jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java
jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java
Centralizes client message authorization and fan-out in a shared delivery pipeline with opaque authorization, captured request context, provider roles, and recipient-specific tag rendering.
  • Extracts MessageDelivery for PRIVMSG, NOTICE, and provider-owned TAGMSG, enforcing target resolution and send gates before delivery.
  • Merges ordinary and sender-copy roles without duplicate self-delivery and evaluates hooks once per immutable envelope.
  • Records inbound-tag ownership so forwarded client tags reach only recipients that negotiated a selecting capability.
jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java
jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java
jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java
jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java
Introduces committed state-transition events and moves away-notify fan-out out of command handlers.
  • Publishes AWAY, JOIN, PART, QUIT, and NICK facts after committed transitions and outside registry/channel locks.
  • Lets listeners produce contained delivery intents while preserving committed state and core confirmations.
  • Updates SAJOIN and disconnect cleanup to publish the appropriate events.
jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java
jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java
jircd-core/src/main/java/net/jircd/core/session/DisconnectCleanup.java
jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java
jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java
jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java
jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/SajoinCommandHandler.java
Replaces capability-aware member formatting and hard-coded reply widths with generic member facts, rendering slots, and provider-declared budgets.
  • Exposes consistent unformatted member facts and renders NAMES/WHO per requester's negotiated hooks.
  • Moves multi-prefix status expansion into its module and declares its two-byte bound through the SPI.
  • Removes legacy reply-budget reservations and updates JOIN, NAMES, WHO, and SAJOIN call paths.
jircd-core/src/main/java/net/jircd/core/session/Channel.java
jircd-core/src/main/java/net/jircd/core/session/MemberFacts.java
jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java
jircd-core/src/main/java/net/jircd/core/session/JoinResult.java
jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java
jircd-core/src/main/java/net/jircd/core/session/command/NamesCommandHandler.java
jircd-core/src/main/java/net/jircd/core/session/command/WhoCommandHandler.java
jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java
jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java
jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java
Generalizes output grouping and queue admission so providers can frame finite groups atomically for eligible recipients.
  • Replaces enqueueBatch with enqueueGroup and provider-supplied validated framing plans.
  • Queues opener, members, and closer as one entry, preserving group integrity under SendQ overflow, provider withdrawal, and concurrent writes.
  • Moves BATCH references, wrappers, prerequisite gating, and batch tags into the batch module.
jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java
jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java
jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java
jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java
Documents the completed SPI ownership model and adds broad unit, integration, absence, composition, protocol, load, and static-analysis validation.
  • Adds focused tests for each migrated provider plus shared delivery, events, member rendering, group admission, composition, and missing-provider behavior.
  • Updates README, protocol/data-model contracts, migration guidance, and validation records while explicitly excluding runtime lifecycle and fault-isolation claims.
  • Reports a successful full build with 1,254 passing tests, clean static analysis, and unchanged baseline irctest outcomes.
README.md
specs/001-ircv3-server/contracts/irc-protocol-commands.md
specs/001-ircv3-server/data-model.md
specs/011-away-notify-batch/contracts/away-notify-and-batch.md
specs/014-capability-plugin-spi/tasks.md
specs/014-capability-plugin-spi/validation/pr-101/migrations.md
specs/014-capability-plugin-spi/validation/pr-101/results.md
specs/014-capability-plugin-spi/validation/pr-101/irctest.md
jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java
jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java

Assessment against linked issues

Issue Objective Addressed Explanation
#101 Move message-tags, echo-message, away-notify, multi-prefix, and batch behavior into their capability modules using the capability SPI, leaving core with generic mechanisms rather than capability-specific behavior. ✅
#101 Ensure removing a capability module removes its offer and behavior, while preserving the other capabilities and core behavior. ✅
#101 Remove the temporary contributeTags/includeSenderInFanOut bridge and document the public API migration path. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot 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.

Hey - I've found 5 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java" line_range="79" />
<code_context>
+              after,
+              new Audience(() -> List.copyOf(audience.get())));
+      for (EventIntent intent : extensionRegistry.hookDispatcher().event(snapshot, event)) {
+        Recipients.deliver(intent.recipients(), intent.message());
+      }
+    } catch (RuntimeException e) {
</code_context>
<issue_to_address>
**Slow hooks stall requests**

When an enabled provider hook blocks indefinitely or takes an unusually long time, provider callbacks run synchronously on the command or enqueueing thread, and the exception handling only catches callbacks that return or throw. A stalled event, inbound-tag, or framing hook therefore blocks that thread indefinitely, delaying command replies or output-group admission.

Run hooks behind a bounded execution policy or otherwise ensure a stalled provider cannot block command handling and group admission.

Also at `jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java:196`, `jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java:215`.
</issue_to_address>

### Comment 2
<location path="jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java" line_range="72" />
<code_context>
+    if (recipients.isEmpty()) {
+      return;
+    }
+    shared.deliver(
+        session,
+        COMMAND_NAME,
</code_context>
<issue_to_address>
**Unnegotiated senders can use TAGMSG**

When a registered client sends `TAGMSG` without negotiating `message-tags`, `TagmsgCommandHandler` authorizes and delivers `TAGMSG` without checking the sender’s negotiated capabilities, and the new test explicitly expects this; clients that have not negotiated `message-tags` can therefore use a command whose extended-command contract requires that negotiation.

Enforce the documented sender precondition or update the extended-command contract to explicitly permit unnegotiated senders.
</issue_to_address>

### Comment 3
<location path="jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java" line_range="59-60" />
<code_context>
     // An empty trailing argument clears away status the same
     // way a fully-absent one already does, rather than setting an empty-string away reason.
     if (message.params().isEmpty() || message.params().getFirst().isEmpty()) {
+      String previous = session.awayReason();
       session.setAwayReason(null);
       Replies.sendFitted(
</code_context>
<issue_to_address>
**Away notices can contradict final state**

When two threads invoke `AwayCommandHandler.handle` for the same session and one pauses after setting its reason while the other sets and publishes a later reason, concurrent `AWAY` handlers can interleave their reason reads, writes, and `awayChanged` publications. One handler can publish after a later state change, so peers receive notices in an order that leaves their observed away status different from the session’s final reason.

Serialize each session’s AWAY read, state update, and event publication so later transitions cannot publish ahead of earlier ones.

Also at `jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java:47-48`.
</issue_to_address>

### Comment 4
<location path="jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java" line_range="144-147" />
<code_context>
     Message joinNotification =
         new Message(Map.of(), presentedForm, Command.JOIN, "JOIN", List.of(channel.name()));
     Recipients.deliver(result.recipients(), joinNotification);
+    extensionRegistry
+        .committedEvents()
+        .joined(session, presentedForm, channel.name(), result.recipients());

     Channel.Topic topic = channel.topicState();
     if (topic != null) {
</code_context>
<issue_to_address>
**Committed events arrive out of order**

When two threads commit joins to the same channel and the first pauses before publishing, the join commits before this publication call, but `CommittedEvents.publish` assigns `operationId` only when publication begins. A second thread can commit and publish a later join first, so listeners receive events in the opposite order from the channel transitions and can end with stale derived state.

Assign an ordering token at the transition commit and ensure listeners observe committed events in that order.

Also at `jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java:73`.
</issue_to_address>

### Comment 5
<location path="jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java" line_range="93-100" />
<code_context>
     Message joinNotification =
         new Message(Map.of(), presentedForm, Command.JOIN, "JOIN", List.of(channel.name()));
     Recipients.deliver(result.recipients(), joinNotification);
+    extensionRegistry
+        .committedEvents()
+        .joined(session, presentedForm, channel.name(), result.recipients());
</code_context>
<issue_to_address>
**Kicked members emit no event**

When a capability listener relies on committed membership-departure events and a member is removed with KICK, `KickCommandHandler` removes the target and sends the KICK notification, but never publishes a committed event; unlike this PART path, extension listeners therefore receive no fact about a KICK departure.

Publish a committed KICK/departure event from the successful KICK path after the membership transition, with the captured audience and kick details.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 5 findings to address first, and the shared delivery path now decides which channel and direct-message recipients are authorized, while capability hooks add sender copies and control which client tags reach which peers. A faulty gate or recipient selection could expose messages or tags that have already been sent; reverting restores the previous delivery path, but cannot recall that traffic.

Blocking findings: jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java:79, jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java:72, jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java:60, jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java:147, jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java:100


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

codegeek and others added 2 commits October 23, 2026 18:52
Sourcery noted that a successful KICK removed a member without
publishing anything, so providers listening for membership changes
missed kicks. KICK now publishes EventKind.KICKED after the transition,
carrying the kicker as the event's actor. The victim's identity is
captured before the removal (RFC1459 folding), so a racing NICK can't
rename the event; if the capture fails, the KICK still proceeds and
only the event is skipped.

Sourcery also asked about ordering between committed events. The
guarantee is now stated where it holds: events a session causes with
its own commands are published in the order it issued them. Events
another session causes about it, and events about different subjects,
have no relative order.

Refs #101

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The KICKED event and ordering tests added 8 jircd-core tests, so the
recorded build figures were stale.

Refs #101

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@codegeek
codegeek merged commit 193752d into main Oct 24, 2026
6 checks passed
@codegeek
codegeek deleted the 014-capability-migrations branch October 24, 2026 01:10
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.

[Feature] Move message-tags, echo-message, away-notify, multi-prefix and batch onto the capability SPI

1 participant