ServerlessIRCd — Extensions Plan (post-v1)
Plan for the IRC features that v1 deliberately left out: the remaining
IRCv3 client-server extensions and IRC services (NickServ /
ChanServ / OperServ / HostServ / MemoServ). Each section is scoped,
mapped onto the existing hexagonal architecture, and proposed as
tickets for tickets.md / progress.md once ratified.
Status: planning. Nothing here has landed. Ticket numbers below start at TICKET-110 (next free id per
todo.md). When this plan is ratified the tickets get appended totickets.md/progress.mdunder two new phase headings (Phase 12 — IRCv3 extensions, Phase 13 — IRC services) and the corresponding sections here are superseded.
1. Goals & non-goals
Goals
- Close the gap between the v1 cap set and what mainstream clients (IRCCloud, TheLounge, Textual, gamja) actually negotiate today.
- Add the IRCv3 features that compose with work already shipped:
account-tagis meaningless withoutaccount-notify;message-tagswithoutmsgid/labeled-responseleaves clients correlating by content. - Provide a services story (NickServ/ChanServ) that fits the pure-reducer + location-of-authority architecture instead of bolting on a pseudo-server.
- Stay serverless-native: every new persistence need is behind a port
(
MessageStore,AccountStore,ServicesStore) so the same core runs on Cloudflare and AWS unchanged.
Non-goals (this plan)
- Server-to-server linking (TS6, IRCv3 S2S, SALR). Remains a
non-goal, same as v1 (
PLAN.md§1). AServerLinkseam is the documented design-for-later; no S2S work is proposed here. - Atheme / Anope compatibility. Services are integrated into the daemon, not implemented as a separate pseudo-server speaking a link protocol. See §6 architecture decision.
- Full services suite parity. BotServ and StatServ are explicitly out of scope; OperServ ships a minimal AKILL/JUPE set only.
- Plaintext :6667 support. Still TLS-only (ADR-009).
2. Current cap set (baseline)
packages/irc-core/src/caps/capabilities.ts:28 advertises:
account-tag, away-notify, batch, chghost, draft/chathistory,
echo-message, extended-join, invite-notify, message-tags,
multi-prefix, safelist, sasl=PLAIN[,EXTERNAL], server-time
What follows fills in the missing pieces, grouped by spec maturity.
3. Missing IRCv3 extensions
3.1 Stable spec extensions (high-value, low-risk)
| Cap | Spec | What it adds | Effort | Depends on |
|---|---|---|---|---|
account-notify |
ircv3.net/specs/extensions/account-notify-3.2 | Push ACCOUNT messages to peers in shared channels on login/logout (complements account-tag, extended-join) |
S (2 pts) | SASL AccountStore wiring (TICKET-071 ✅) |
msgid |
ircv3.net/specs/extensions/message-ids | Stable per-message id as a msgid tag on every PRIVMSG/NOTICE to cap-enabled peers (live fanout), sharing the single IdFactory.nonce() value also recorded for chathistory replay; needed for draft/read-marker, replies, dedup |
S (2 pts) | message-tags ✅, IdFactory ✅ |
standard-replies |
ircv3.net/specs/extensions/standard-replies-3.0 | Replace a class of bare numerics with FAIL/WARN/NOTE for cap-enabled clients; better client UX |
M (3 pts) | none |
monitor |
ircv3.net/specs/extensions/monitor-3.2 | MONITOR command, modern replacement for ISON; per-connection watchlist + 730/731/732/733/734 numerics |
M (5 pts) | Registry lookups ✅ |
labeled-response |
ircv3.net/specs/extensions/labeled-response-3.2 | Client tags a request with label; server wraps the reply in a BATCH carrying that label. Composes with batch ✅ |
L (8 pts) | batch ✅, message-tags ✅ |
sts |
ircv3.net/specs/extensions/sts | Strict Transport Security: advertise a TLS+port policy to upgrade plaintext/WebSocket clients. Relevant once raw TCP :6697 is exposed (it is, per Phase 7 ✅) |
S (2 pts) | dual transport ✅ |
Notes:
account-notifyis the cheapest win and unblocks theaccount-tag/extended-join/account-notifytriple that modern clients render fully.msgidis mostly there: the chathistory work (TICKET-063) already produces stable msgids viaIdFactory.nonce(). This ticket is the cap advertisement + emitting the tag on every PRIVMSG/NOTICE/TAGMSG, not just recorded ones.labeled-responseis the largest item because the request→reply correlation touches every reducer that emits a client-facing reply; theBATCHwrapper already exists (TICKET-025), so the work is in plumbing a per-request label throughctx.stsis config-driven only (no protocol state); the value is advertising the right TLS port for the deployed adapter.
3.2 Draft / spec-pending extensions
| Cap | Spec | What it adds | Effort | Depends on |
|---|---|---|---|---|
draft/pre-away |
ircv3 draft | Persist away status + message across reconnects; cross-device away sync. Noted out-of-scope in TICKET-063. | M (3 pts) | AccountStore ✅, services UserStore (§6) |
draft/read-marker |
ircv3 draft | Per-(user, channel) last-read marker, propagated via +draft/read-marker tag. The chathistory plumbing (TICKET-063) already keeps the marker locally; this ticket persists it across connections and shares it. |
M (5 pts) | chathistory ✅, services UserStore |
draft/multiline |
ircv3 draft | BATCH +id draft/multiline :target for multi-line messages up to a byte budget. Clients (TheLounge, gamja) render as joined blocks. |
M (5 pts) | batch ✅, message-tags ✅ |
draft/typing |
ircv3 client-behavior/typing | `TAGMSG +draft/typing=active | paused | doneto channel members (excluding sender unlessecho-message`). Client UX only; no server state. |
Notes:
- The
draft/prefix caps stay namespaced until the spec stabilizes, matching the existingdraft/chathistoryprecedent. draft/typingis a near-trivial follow-on from TAGMSG (TICKET-076 ✅): just whitelist the+draft/typing=…client tag and broadcast.
3.3 Already-shipped caps blocked on adapter wiring
Worth calling out explicitly so they are not re-implemented by mistake:
| Cap | Status |
|---|---|
account-tag |
Reducer emits; only meaningful once AccountStore is bound per adapter (TICKET-071 ✅). |
extended-join |
Reducer emits the account realname form; same dependency as above. |
draft/chathistory |
Reducer + in-memory store shipped (TICKET-063 ✅); adapter MessageStore wiring landed in TICKET-070 ✅. Persistent CF (D1/DO) and AWS (DynamoDB) stores are the next step. |
msgid |
Standalone cap advertised and emitted on the live PRIVMSG/NOTICE fanout (TICKET-111 ✅). A single IdFactory.nonce() is shared between the live @msgid=<id> tag and the chathistory record so the two match for the same message. |
4. Missing ISUPPORT tokens
packages/irc-core/src/commands/isupport.ts should grow:
MONITOR=<n>— maximum entries per connection (TICKET formonitor).ACCOUNTEXTBAN/EXTBAN=~,acc— extended ban by account (services+rwork).STATUSMSG=@+— prefixes that map to status-msg delivery (ships with anySTATUSMSG-aware PRIVMSG change).TYPING— draft advertisement whendraft/typinglands.
5. Channel + user modes that arrive with services
The v1 mode set (o v b i k l t n m s p channel, i o w s user) gains:
| Mode | Type | Meaning | Source |
|---|---|---|---|
r (channel) |
A | Channel is registered with ChanServ | services |
R (channel) |
B | Block join/message from unregistered nicks | services |
M (channel) |
B | Mod speak requires registered nick (vs. +m = op) |
services |
c (channel) |
B | Block ANSI color (requires message inspection) | optional |
r (user) |
— | User is identified to NickServ | services |
q (user, recv-only) |
— | Protected from oper kick (U-line / services) | services |
S (user) |
— | Connected via TLS (set by transport, read-only) | transport |
S is a near-freebie — the transport already knows whether TLS is in
use (ConnectionActor accepts a transportMeta); surfacing it as a
user mode costs one reducer branch.
6. IRC services
6.1 Architecture decision: integrated services (not pseudo-servers)
Two designs exist in the wild:
A. Pseudo-client services (Atheme / Anope model). A separate
process connects as one or more virtual IRC clients named NickServ
etc.; users interact via PRIVMSG NickServ :REGISTER hunter2 email.
The link between ircd and services speaks a server-to-server protocol
(TS6 / P10 / IRCv3 S2S).
B. Integrated services. Service names are reserved nicks
recognized by the daemon at PRIVMSG time and routed to dedicated
reducers backed by a ServicesStore port. No S2S, no separate process,
no pseudo-client connection.
Decision: B — integrated services. Rationale:
- S2S is an explicit non-goal (
PLAN.md§1). Model A requires it. - The pure-reducer + location-of-authority architecture already has the
right seam: a new
ServicesStoreport (packages/irc-core/src/ports.ts, mirroringAccountStore/MessageStore) absorbs the side effects, and reducers stay pure. - One storage layer is shared with the existing SASL
AccountStore(TICKET-071 ✅) — a NickServ registration is a SASL account, so the services work composes with auth rather than duplicating it. - The serverless substrates (Durable Objects, DynamoDB) handle persistence natively; spinning up a separate long-running services process would defeat the deployment model.
Trade-off documented: integrated services are not binary-compatible
with existing Atheme/Anope modules. Operators migrating from a
services-aware network should expect to re-register nicks and channels.
This is called out in docs/ when the work ships.
6.2 The ServicesStore port
/** Persists services-owned state. Mirrors the AccountStore/MessageStore pattern. */
export interface ServicesStore {
// NickServ
registerNick(account: string, passwordHash: string, email?: string): Promise<void>;
verifyNick(account: string, password: string): Promise<boolean>;
dropNick(account: string): Promise<void>;
isRegisteredNick(account: string): Promise<boolean>;
setNickEnforce(account: string, mode: 'none' | 'kill' | 'ghost'): Promise<void>;
// ChanServ
registerChannel(chan: string, founder: string): Promise<void>;
dropChannel(chan: string): Promise<void>;
getChannelFounder(chan: string): Promise<string | null>;
setChannelAccess(chan: string, account: string, level: AccessLevel): Promise<void>;
// HostServ
setVhost(account: string, vhost: string): Promise<void>;
getVhost(account: string): Promise<string | null>;
// MemoServ
recordMemo(to: string, from: string, body: string): Promise<void>;
listMemos(account: string): Promise<Memo[]>;
markMemoRead(account: string, id: string): Promise<void>;
}
Adapter implementations:
| Adapter | Backend |
|---|---|
| in-memory-runtime | Maps, used by local-cli + tests |
| cf-adapter | D1 (shared with the SASL store from TICKET-093 ✅) or a dedicated ServicesDO |
| aws-adapter | DynamoDB Accounts table extended (TICKET-072 ✅ already provisions it) + new Channels/Memos tables |
6.3 Services scope
| Service | In scope | Out of scope (this pass) |
|---|---|---|
| NickServ | REGISTER, IDENTIFY, LOGOUT, GHOST, DROP, INFO, SET ENFORCE/PASSWORD/EMAIL, link to SASL account, +r user mode, nick enforcement |
CertFP (covered by mTLS SASL EXTERNAL ✅), grouped nicks (alias list) |
| ChanServ | REGISTER, IDENTIFY, DROP, SET FOUNDER/MLOCK/RESTRICTED/KEEPTOPIC, ACCESS/LEVELS (founder/sop/aop/vop/hop), +r/+R/+M channel modes, KEEPTOPIC persistence |
ACL inheritance, fantasy commands, OP/DEOP shorthand (defer) |
| HostServ | ON/OFF/REQUEST/SET (oper), integrate with the existing cloak machinery (packages/irc-core cloaking shipped in Phase 5), +t user mode (vhost in use) |
per-channel vhosts |
| OperServ | AKILL (network-wide ban, persisted + applied on connect), JUPE (block a nick/server name locally), RAW (oper-only, useful for recovery) |
STATS aggregation (TICKET-104 🔄 covers server stats), IGNORE list |
| MemoServ | SEND, LIST, READ, DEL, FORWARD, send-on-identify (queued memos delivered at login) |
channel memos, group memos |
| BotServ | — | Out of scope entirely. Channel-joined bots are a runtime cost serverless deployments should not absorb by default. Operators can run their own bots as ordinary clients. |
| StatServ | — | Out of scope. STATS (TICKET-104) + observability dashboards (TICKET-045 ✅) cover it. |
6.4 Nick enforcement policy
NickServ SET ENFORCE <mode> decides what happens when an
unidentified client uses a registered nick:
none(default): warning notice only.ghost: kick the existing unidentified client off after a grace period (default 30s); connection is force-disconnected via the existingdisconnect(conn, reason)runtime method.kill: same as ghost but immediately.
Implemented as a new EnforceNick effect emitted from the registration
reducer when the chosen nick matches a registered account the
connection is not identified as. The dispatch layer resolves it against
runtime.disconnect(...).
6.5 Services ↔ daemon hooks
Already-present seams the services reuse:
AccountStore.verify('PLAIN', ...)(TICKET-026 ✅) → a successful NickServIDENTIFY, a SASL PLAIN login, and a PASS-based account login (TICKET-152 ✅) all reduce to "this connection is now account X". The sharedapplyAccountSuccess(state, ctx, account)helper stamps the account, sets the+ruser mode, restores per-account state (read markers, persisted away, queued memos), and emits900 RPL_LOGGEDIN+903 RPL_SASLSUCCESSplus theACCOUNTbroadcast toaccount-notify-capable peers.- PASS-based account auth (TICKET-152 ✅) — a third account-login
entry point alongside SASL and NickServ
IDENTIFY. A client that sendsNICK <nick>thenPASS <nick>:<password>(thenUSER) is identified toctx.accountsat registration completion without speaking SASL. The<nick>:<password>literal matches theSASL_ACCOUNTSseed format. The payload nick (left of the first:) is the account name and MUST equalstate.nickat registration completion. Order of precedence at registration is: (1) already identified (SASL/PASS-auth) → gate satisfied; (2)<nick>:<password>verifies againstctx.accounts→ account set; (3) barePASSmatchesserverPassword→ gate satisfied; (4) else464 ERR_PASSWDMISMATCH+ disconnect. A barePASS <value>(no colon) is always a server-password candidate, never an account credential; whenctx.accountsis unbound the<nick>:<password>form is ignored and behaviour collapses to the server-password gate. Like SASL, PASS-auth runs throughapplyAccountSuccessand therefore stamps+r; a failed verify reveals nothing — unknown nick and wrong password are indistinguishable (a fixed dummy verify equalises the failure path). MessageStore(TICKET-063 ✅) → MemoServ'sSENDis a record into a per-account memo queue; delivery is a fanout at identify time.clock/IdFactoryports → memo ids, enforcement grace timers.
7. Proposed tickets
Ticket numbers are placeholders starting at TICKET-110 (next free
id per todo.md). Final ids assigned at ratification. Each ticket
follows strict TDD (Red → Green → Refactor) per CLAUDE.md /
AGENTS.md.
Phase 12 — IRCv3 extensions
TICKET-110 — IRCv3 account-notify (push ACCOUNT on login/logout)
- Phase: 12
- Points: 2
- Dependencies: TICKET-071 (
AccountStorewiring, ✅) - Description: Advertise
account-notify; emitACCOUNT <name|*>to peers in shared channels (excluding sender unlessecho-message) on SASL success, NickServ IDENTIFY (services), andLOGOUT. Reuses theextended-join/chghostbroadcast helper. - Acceptance Criteria:
CAP LSincludesaccount-notify.- A login by peer A visible to peer B (sharing a channel, cap
negotiated) produces
:A ACCOUNT alice. - A logout produces
:A ACCOUNT *. - No
ACCOUNTmessages to clients without the cap. - 100% coverage on the new broadcast branch.
- TDD Outline: Red on "B sees
ACCOUNTafter A's SASL success"; green via the broadcast helper; then cap-gating + echo-message path.
TICKET-111 — IRCv3 msgid (emit msgid tag on every message)
- Phase: 12
- Points: 2
- Dependencies: TICKET-063 (chathistory msgid plumbing, ✅)
- Description: Advertise
msgidas a standalone cap. Every PRIVMSG/NOTICE/TAGMSG emitted to cap-enabled clients carries a+msgid=<id>tag fromIdFactory.nonce(). The chathistory reducer already records the msgid; this ticket surfaces it on the live fanout line, not just on replay. - Acceptance Criteria:
CAP LSincludesmsgid.- Live PRIVMSG to a cap-enabled peer carries
@+msgid=<id>. - The msgid on the live line and on the chathistory replay line match for the same recorded message.
- Clients without the cap see no
msgidtag (existing behavior). - 100% coverage on the tag-emission branch.
- TDD Outline: Red on "peer with
msgidcap sees@+msgid=…on a PRIVMSG"; green; then assert parity with the recorded msgid.
TICKET-112 — IRCv3 standard-replies (FAIL/WARN/NOTE)
- Phase: 12
- Points: 3
- Dependencies: —
- Description: Advertise
standard-replies. For cap-enabled clients, replace a curated subset of bare numeric error replies (461 ERR_NEEDMOREPARAMS,403 ERR_NOSUCHCHANNEL,432/433registration errors, SASL failures) with the structuredFAIL COMMAND code context :descriptionform. The code-path branches that should not change (numeric-only replies like MOTD lines, NAMES) are left on the numeric form. - Acceptance Criteria:
CAP LSincludesstandard-replies.- For each curated numeric, a cap-enabled client receives a
FAIL/WARN/NOTEline; a legacy client receives the unchanged numeric. - The mapping is data-driven (a
numericToStandardReplytable), not scatteredif (hasCap)checks. - 100% coverage on the table + dispatch helper.
- TDD Outline: Red on
461→FAILfor cap-enabled client; green via the table; then each curated code; then the legacy fallback.
TICKET-113 — IRCv3 MONITOR (modern ISON replacement)
- Phase: 12
- Points: 5
- Dependencies: —
- Description: Advertise
MONITOR=<n>(configurable max, default 30). Implement theMONITOR + nick{,nick}/-/C/L/Ssubcommands and the730 RPL_MONONLINE/731 RPL_MONOFFLINE/732 RPL_MONLIST/734 ERR_MONLISTFULLnumerics. Online/offline transitions push730/731asynchronously via the Registry's nick reserve/release hooks. - Acceptance Criteria:
005advertisesMONITOR=30.MONITOR +alicewhile alice is offline →731.- Alice connects →
730pushed to the watcher. MONITOR Llists the watchlist;MONITOR Cclears it.- Over-limit →
734with the rejected nick. - 100% coverage on the reducer and the async push hook.
- TDD Outline: Red on the offline→
731path; green; then the online push; then list/clear/over-limit.
TICKET-114 — IRCv3 labeled-response (correlate request→reply via BATCH)
- Phase: 12
- Points: 8
- Dependencies: TICKET-025 (
batch, ✅) - Description: Advertise
labeled-response. A client sends a message with+label=<id>; the server wraps every reply caused by that message in aBATCH +id labeled-response … BATCH -id. If the request produces no reply, the server emits an empty batch. The label is plumbed throughctxso every reducer's emitted effects inherit it. - Acceptance Criteria:
CAP LSincludeslabeled-response.- A
PRIVMSGcarrying+label=fooproduces aBATCH +foo labeled-response(echo + any numerics)BATCH -foofor cap-enabled clients. - A command that produces no reply (e.g. a no-op
PINGalready covered byPONGis not empty; aJOINto a nonexistent channel that 403s still wraps the 403) — spec edge cases enumerated. - 100% coverage on the label plumbing + wrapping helper.
- TDD Outline: Red on "PRIVMSG with
+label=foowraps echo in BATCH"; green; then the no-reply case; then the multi-reply case.
TICKET-115 — IRCv3 sts (Strict Transport Security policy)
- Phase: 12
- Points: 2
- Dependencies: dual transport (Phase 7 ✅)
- Description: Advertise
stswith a deployment-configurable policy (duration,portfor the TLS listener, optionalpreload). On a plaintext/WebSocket connection (when one exists), the server emits theSTSpolicy so compliant clients upgrade; on the TLS connection the policy is still sent to lock in the duration. - Acceptance Criteria:
CAP LSincludessts=duration=…,port=6697.- Values come from
ServerConfig, not literals. - A wss / irc+tls connection advertises the duration.
- 100% coverage on the policy builder.
- TDD Outline: Red on cap advertisement; green; then override via config.
TICKET-116 — IRCv3 draft/typing (typing indicator broadcast)
- Phase: 12
- Points: 2
- Dependencies: TICKET-076 (
TAGMSG, ✅) - Description: Advertise
draft/typing. A client sendsTAGMSG <target> +draft/typing=active|paused|done; the server broadcasts to channel members (or routes to a user target), excluding the sender unlessecho-messageis negotiated. No state is kept server-side. - Acceptance Criteria:
CAP LSincludesdraft/typing.- Channel typing TAGMSG fans out to cap-enabled members.
- The
+draft/typing=*client tag is whitelisted for clients withoutmessage-tags(per the spec's client-tag exception). - 100% coverage on the whitelist + broadcast.
- TDD Outline: Red on channel typing fanout; green; then echo-message path; then user-target path.
TICKET-117 — IRCv3 draft/multiline (multi-line BATCH)
- Phase: 12
- Points: 5
- Dependencies: TICKET-025 (
batch, ✅) - Description: Advertise
draft/multilinewith a configurable byte budget (default 4096). A cap-enabled client sendsBATCH +id draft/multiline :targetfollowed by N PRIVMSG/NOTICE lines, thenBATCH -id. The server concatenates per spec (with\n), enforces the byte cap, and broadcasts as a single PRIVMSG carrying a+draft/multilinetag plus the joined body (or a batched replay to other multiline-capable peers — spec calls for the joined form to legacy peers and the batch form to cap peers). - Acceptance Criteria:
CAP LSincludesdraft/multiline=35615(or configured max).- Multi-line batch from a cap-enabled client is delivered to
cap-enabled peers as a
draft/multilineBATCH. - Legacy peers receive the joined single PRIVMSG.
- Over-budget batch is rejected with
FAIL. - 100% coverage on the reducer + byte-budget enforcement.
- TDD Outline: Red on a 2-line batch delivered as joined PRIVMSG to a legacy peer; green; then the cap-peer batched delivery; then budget enforcement.
TICKET-118 — IRCv3 draft/read-marker (persistent last-read)
Phase: 12
Points: 5
Dependencies: TICKET-063 (chathistory, ✅), services
UserStore(TICKET-122)Description: Advertise
draft/read-marker. The chathistory work already keeps a per-(connection, channel)last-read marker inConnectionState. This ticket persists the marker per(account, channel)inServicesStore, propagates updates via+draft/read-marker=<msgid>tags on relevant messages, and seeds a fresh connection's marker from the persisted value at identify time.Acceptance Criteria:
CAP LSincludesdraft/read-marker.- Marking a message read updates the persisted marker.
- Reconnect restores the marker.
CHATHISTORY BEFOREhonours the marker in the absence of an explicit pivot.- 100% coverage on the persistence + tag emission.
TDD Outline: Red on reconnect-restore; green via the store; then tag emission; then CHATHISTORY integration.
Implementation notes (shipped): Landed as a focused
ReadMarkerStoreport (get/set/forAccount) plus anInMemoryReadMarkerStorereference impl inirc-core/ports.ts, rather than waiting on the full servicesUserStore(TICKET-122). The mark is delivered as aTAGMSGcarrying+draft/read-marker=<msgid>(msgid-based, composing with the chathistory marker plumbing, diverging from the work-in-progress ircv3 draft's timestampedMARKREADverb). A mark TAGMSG is persisted viapersistReadMarker, fanned out tomessage-tagsORdraft/read-markerpeers (mirroring thedraft/typingprecedent), and+draft/read-markeris whitelisted infilterClientTagsso draft-only peers keep the tag. SASL identify seedslastReadMarkersviaseedReadMarkers;CHATHISTORY BEFOREwith no pivot falls back to the connection's marker. TICKET-122 should fold this store into the unified servicesUserStorewhen it lands.Divergence vs. spec compliance (TICKET-150 follow-up, shipped): TICKET-118 shipped channel-wide mark TAGMSG fanout for pragmatic parity with the
draft/typingprecedent — everymessage-tagsORdraft/read-markerpeer in the channel received the mark. The ircv3draft/read-markerspec, however, mandates that a read marker MUST NOT be disclosed to other users: a client's own mark is private to its account. The Phase 14 follow-up (account-scopeddraft/read-markerfanout) restored spec compliance with two behaviour changes:- Identified originator → account-scoped fanout. The
tagmsgChannelReducernow annotates the markTAGMSGBroadcast effect with the originator'saccount. The dispatch layer's cap-awarebroadcast()restricts the recipient set to channel members authenticated as that same account (RFC 1459 case-folded comparison), still honouringexcept(originator skip) and the OR-semanticscapsfilter (message-tagsORdraft/read-marker). Other accounts in the channel never receive the mark; the originator's other live sessions on the same account (multi-device sync, when they share the channel) receive it.filterClientTagsand its+draft/read-markerwhitelist are unchanged — only the recipient set changes. - Unidentified originator → no fanout (session-local). An
unidentified connection (no account) emits no Broadcast for a read-
marker mark. Its mark stays session-local (the in-memory
lastReadMarkersstill advances; persistence is a no-op without an account, unchanged from TICKET-118). The originator's own client still observes its mark via theecho-messageself-echo. This matches the spec's privacy default: a read marker never reaches other users, and an unidentified connection has no account to share a mark with.
Recipient-resolution primitive ("or equivalent" index). The ticket's description floated an
AccountConnectionsindex on the runtime. The shipped implementation takes the spec-permitted "(or equivalent)" path: there is no separate index structure. The recipient set is resolved at dispatch time by walking the channel roster and consulting each member's liveConnectionState.accountvia the existinggetConnectionInfoport method (the same per-recipient resolution already used for cap gating). This is always fresh by construction — disconnected connections leave the roster, and account changes are live references — so the staleness concerns an explicit index would need (re-sync onACCOUNT/SASL/ disconnect) do not apply. Global cross-channel account fanout (reaching an account's sessions that are not in the marked channel) is a documented follow-up, not needed for spec privacy: the mark is channel-specific, and the persistence layer (ServicesStore.setLastReadMarker) already syncs a session's marker on reconnect/rejoin viaseedReadMarkers. 100% coverage on the account-scoped fanout + dispatch filter (irc-core and irc-server both enforce a 100% gate).- Identified originator → account-scoped fanout. The
TICKET-119 — IRCv3 draft/pre-away (persist away status)
- Phase: 12
- Points: 3
- Dependencies: TICKET-061 (
AWAY, ✅), servicesUserStore(TICKET-122) - Description: Advertise
draft/pre-away. Persist away reason inServicesStorekeyed by account. On identify (or SASL login), the server re-applies the away state and emits theAWAYline +306 RPL_NOWAWAYas if the user had sentAWAY :reasonthemselves. Noted out-of-scope in TICKET-063. - Acceptance Criteria:
CAP LSincludesdraft/pre-away.- An identified user who previously went
AWAY :brband reconnects is auto-marked away. AWAY(unset) clears the persisted reason.- 100% coverage on the persistence + replay.
- TDD Outline: Red on reconnect auto-away; green; then the unset path.
- Implementation notes (shipped): Landed as a focused
AwayStoreport (get/set/clear) plus anInMemoryAwayStorereference impl inirc-core/ports.ts, rather than waiting on the full servicesUserStore(TICKET-122) — same shape as the TICKET-118ReadMarkerStoreprecedent. TheawayReducercallspersistAwayon set andclearPersistedAwayon unset (gated on a bound store AND an identified connection, so unidentified sessions still track their own in-memoryawayfor the duration of the connection). SASL identify callsreplayPersistedAwayto copy a stored reason onto the fresh connection'sConnectionState.awayand emits a single306 RPL_NOWAWAYso the user sees they are still away. The companionaway-notifybroadcast is intentionally NOT emitted at identify time — the connection has not joined any channels yet, so it would have no audience; peers learn the away state through the normalWHO/WHOIS/AWAYqueries once the user joins. TICKET-122 should fold this store into the unified servicesUserStorewhen it lands.
TICKET-120 — ISUPPORT tokens for new caps + modes
- Phase: 12
- Points: 2
- Dependencies: the cap tickets above + services modes (TICKET-122)
- Description: Add
MONITOR=<n>,STATUSMSG=@+,EXTBAN=~,q,ACCOUNTEXTBAN=a, draft tokens fordraft/typing/draft/multilinetogenerateIsupport. Tokens are config-derived where applicable (max monitor count, extban prefix). - Acceptance Criteria:
- Each token advertises the configured value.
- Tokens only appear when the relevant cap/mode is supported (e.g.
ACCOUNTEXTBANonly once services ship). - 100% coverage on the new branches.
- TDD Outline: Red on each missing token; green by adding it.
TICKET-121 — User mode S (TLS connected)
- Phase: 12
- Points: 1
- Dependencies: dual transport (Phase 7 ✅)
- Description: When the connection is over TLS (wss or irc+tls),
set user mode
Sread-only on the connection at registration. The mode is not settable viaMODE(parse rejects+Swith501). Surface in WHOIS (:server 276 nick target :is using a secure connection) for clients that advertise theRPL_WHOISSECUREcap. - Acceptance Criteria:
- TLS connection →
+Suser mode visible inWHO/WHOIS. MODE nick +Srejected with501 ERR_UMODEUNKNOWNFLAG.- 100% coverage on the mode-set + parse rejection.
- TLS connection →
- TDD Outline: Red on TLS registration setting
+S; green; then the parse rejection.
Phase 13 — IRC services
TICKET-122 — ServicesStore port + in-memory reference impl
- Phase: 13
- Points: 5
- Dependencies: TICKET-029 (
IrcRuntimeport, ✅), TICKET-071 (AccountStore, ✅) - Description: Define
ServicesStore(§6.2) inpackages/irc-core/src/ports.ts. ShipInMemoryServicesStore(in-memory-runtime) covering NickServ/ChanServ/HostServ/MemoServ primitives. Adapter backends (D1, DynamoDB) ship in dedicated follow-on tickets. - Acceptance Criteria:
- Port interface compiles under strict mode.
InMemoryServicesStoreimplements every method; 100% coverage.- Reused by local-cli and unit tests.
- TDD Outline: Red on
registerNick/verifyNickround-trip; green; then each method.
TICKET-123 — NickServ: REGISTER / IDENTIFY / DROP / INFO + +r
- Phase: 13
- Points: 8
- Dependencies: TICKET-122
- Description: Wire
PRIVMSG NickServ :<command>to a NickServ reducer.REGISTER password emailcreates a SASL account (reuses the existingAccountStoreso SASL PLAIN works immediately after registration).IDENTIFY passwordlogs the current connection in (sets+ruser mode, emitsACCOUNTtoaccount-notifypeers).DROPremoves the registration.INFO [nick]queries it. Successful IDENTIFY triggers MemoServ delivery of queued memos. - Acceptance Criteria:
REGISTER hunter2 alice@example.comfollowed by SASL PLAIN withalice:hunter2succeeds.IDENTIFY hunter2from an unidentified connection sets+rand emitsACCOUNT.DROP alicerequires re-identify.- 100% coverage on the reducer + state transitions.
- TDD Outline: Red on REGISTER→SASL round-trip; green; then IDENTIFY; then DROP; then INFO.
TICKET-124 — NickServ: nick enforcement (SET ENFORCE)
- Phase: 13
- Points: 5
- Dependencies: TICKET-123
- Description: Implement
NickServ SET ENFORCE none|ghost|killplus theEnforceNickeffect. When a client registers (NICK) with a nick that is registered to an account they are not identified as, the policy fires after the configured grace period.ghostforce- disconnects;killforce-disconnects immediately. Default policy per registered account isnone. - Acceptance Criteria:
SET ENFORCE ghost+ unidentified use of the nick → grace period →disconnect("Nick enforced by NickServ").SET ENFORCE kill→ immediate disconnect.SET ENFORCE none→ warning notice only.- 100% coverage on the effect + dispatch integration.
- TDD Outline: Red on grace-period disconnect; green; then each policy.
TICKET-125 — ChanServ: REGISTER / DROP / SET + +r/+R/+M
Phase: 13
Points: 8
Dependencies: TICKET-123, TICKET-011 (
MODE, ✅)Description: Wire
PRIVMSG ChanServ :<command>.REGISTERrecords the channel founder; sets channel mode+r.SET FOUNDER/MLOCK/RESTRICTED/KEEPTOPICupdates the persisted channel record. New channel modes:+r(registered, settable only by ChanServ),+R(block join/message from unidentified),+M(mod speak requires identification).KEEPTOPICpersists the topic and restores it on the first join after the channel becomes empty.Acceptance Criteria:
REGISTER #chanby an identified founder sets+r.+Rblocks join from unidentified (519 ERR_ALLMUSTREGISTER, server-specific numeric) and PRIVMSG (404).KEEPTOPICround-trips a topic across empty-recreate.MLOCKreapplies the locked modes if an op tries to unset them.- 100% coverage on the reducer + mode interactions.
TDD Outline: Red on REGISTER→
+r; green; then+R/+M; then KEEPTOPIC; then MLOCK.Implementation notes (post-land):
- Three new boolean channel modes added to
ChannelModes:registered(+r),blockUnidentified(+R),moderatedIdent(+M). They appear in the324 RPL_CHANNELMODEISreply when set, butchannelModeReducerrejects+r/+R/+MviaMODEwith472 ERR_UNKNOWNMODE— only ChanServ may toggle them viaApplyChannelDeltaeffects. 519 ERR_ALLMUSTREGISTERadded toNumericsfor the+RJOIN gate.ServicesStoreextended:getChannel,setChannelFounder,setChannelMlock/getChannelMlock,setChannelRestricted,setChannelKeepTopic,setChannelTopic/getChannelTopic. TheRegisteredChannelrecord now carriesmlock/restricted/keepTopic/topic?. The in-memory store returns defensive copies (callers cannot mutate the store by editing returned records).chanservReducer(new,commands/chanserv.ts) handlesPRIVMSG ChanServ :<command>. Subcommands:REGISTER,DROP,INFO,SET FOUNDER/MLOCK/RESTRICTED/KEEPTOPIC. Channel mutations are emitted asApplyChannelDeltaeffects so the actor applies them to the authoritativeChannelState.SET RESTRICTED ON|OFFtoggles the+Rchannel mode (and stores the preference).SET KEEPTOPIC ON|OFFtoggles topic persistence and drops any previously-stored snapshot when turned off.- JOIN reducer consults services on the first join after empty-recreate
to restore a KEEPTOPIC snapshot (emits
332/333to the joiner).+RJOIN gate rejects unidentified clients with519. - TOPIC reducer persists the snapshot on every change (set or clear) when KEEPTOPIC is on.
- PRIVMSG/NOTICE channel reducers enforce
+M(op/voice/identified pass) and+R(identified pass) with404; NOTICE is silent. channelModeReducerreapplies MLOCK after every applied MODE: any positively-locked boolean mode that ended up unset (whether by this MODE or by prior state) is re-asserted via a ChanServ-emittedApplyChannelDelta+Broadcast(:ChanServ!ChanServ@services MODE #chan +…). Defence in depth — services-only modes (+r) are also defended, so the registered mark survives even if a future code path applies anApplyChannelDeltaclearing it.- Actor routes
PRIVMSG ChanServ(case-insensitive) toreducers.chanserv; NOTICE and unbound-services cases fall through to the normal user-target reducer (RFC: NOTICE is silent). - 100% coverage on
chanserv.ts,mode.ts(reapply branch), and the new join/privmsg/topic branches.
- Three new boolean channel modes added to
TICKET-126 — ChanServ: ACCESS / LEVELS (SOP/AOP/VOP/HOP)
- Phase: 13
- Points: 5
- Dependencies: TICKET-125
- Description:
ChanServ ACCESS #chan ADD nick leveland the shorthandSOP/AOP/VOP/HOPvariants map an account to a privilege level. On JOIN, ChanServ applies the matching channel prefix (@,+) automatically.LEVELSlets the founder redefine what numeric level each command requires. - Acceptance Criteria:
AOP alice ADD→ alice auto-opped on JOIN.VOP bob ADD→ bob auto-voiced.ACCESS #chan DEL aliceremoves.LEVELS SET AUTOOP 5redefines the threshold.- 100% coverage on the access reducer + JOIN hook.
- TDD Outline: Red on AOP→auto-op; green; then DEL; then LEVELS.
TICKET-127 — HostServ: ON / OFF / REQUEST / SET (oper)
- Phase: 13
- Points: 3
- Dependencies: TICKET-122, cloaking (Phase 5 ✅)
- Description: Wire
PRIVMSG HostServ :<command>.REQUEST vhostqueues a request for oper approval (or auto-approves if configured).SET account vhost(oper-only) sets it directly.ON/OFFtoggles the cloak at runtime; the cloak machinery already exists, this ticket wires it to per-account state and emitsCHGHOST(TICKET-022 ✅) on toggle. - Acceptance Criteria:
SET alice example.net+ON→CHGHOSTemitted to peers; WHOIS shows the vhost.OFFrestores the real (cloaked) host.- 100% coverage on the reducer + CHGHOST emission.
- TDD Outline: Red on
ON→CHGHOST; green; thenOFF; thenREQUEST/SETapproval path.
TICKET-128 — OperServ: AKILL / JUPE / RAW
- Phase: 13
- Points: 5
- Dependencies: TICKET-075 (
OPER, ✅), TICKET-122 - Description: Wire
PRIVMSG OperServ :<command>(oper-only; rejected with a services notice otherwise).AKILL ADD mask reasonrecords a network-wide ban persisted inServicesStore; the$connect/$defaultadmission path checks it on every new connection and on every PRIVMSG, force-disconnecting on match.JUPE nickblocks a nick/server name locally (NICK collision reducer returns432for the jupe'd nick).RAW <line>is oper- only and emits a raw IRC line (useful for recovery; logged). - Acceptance Criteria:
AKILL ADD *!*@bad.example.net flooding→ next matching connection is disconnected at$connect.JUPE moo→NICK mooreturns432 ERR_ERRONEUSNICKNAME.- Non-oper → service notice rejection, no state change.
- 100% coverage on each reducer + the admission hook.
- TDD Outline: Red on AKILL disconnect; green; then JUPE; then RAW; then non-oper rejection.
TICKET-129 — MemoServ: SEND / LIST / READ / DEL + queue delivery
- Phase: 13
- Points: 5
- Dependencies: TICKET-123, TICKET-122 (
MessageStorepattern, ✅) - Description: Wire
PRIVMSG MemoServ :<command>.SEND nick bodyrecords a memo against the recipient's account. On successful NickServ IDENTIFY or SASL login, queued memos are delivered (via the existing MemoServ pseudo-client notice format).LIST/READ/DEL/FORWARDround out the surface. - Acceptance Criteria:
SEND alice Hiwhile alice is offline → alice receives the memo on next IDENTIFY.LISTenumerates unread.READ ndelivers memo #n;DEL nremoves.- 100% coverage on the reducer + delivery hook.
- TDD Outline: Red on offline-send→identify-delivery; green; then list/read/del.
TICKET-130 — Adapter backends for ServicesStore
- Phase: 13
- Points: 8
- Dependencies: TICKET-122, all of TICKET-123..129 (reducers landed against the in-memory store first)
- Description: Ship persistent
ServicesStoreimplementations:- CF: D1 (share the database from TICKET-093 ✅), new tables
nickserv_accounts,chanserv_channels,chanserv_access,hostserv_vhosts,memoserv_memos. - AWS: DynamoDB, extending the existing
Accountstable (TICKET-072 ✅) and addingChannels,ChannelAccess,Vhosts,Memos. - Both adapters wire the store into
ConnectionActorOptions.services.
- CF: D1 (share the database from TICKET-093 ✅), new tables
- Acceptance Criteria:
- Each adapter: end-to-end NickServ register→identify→SASL PLAIN.
- Channel registration survives a deploy (state persisted).
- Memos delivered on reconnect after identify.
- 100% coverage on the new store implementations.
- TDD Outline: Red per adapter on REGISTER→survive-restart; green via the persistent store.
TICKET-131 — Docs: services reference + admin tooling
- Phase: 13
- Points: 2
- Dependencies: TICKET-130
- Description:
docs/Services.mdcovering: integrated-services architecture decision (§6.1), per-service command reference, admin tooling (tools/seed-nickserv-accounts.tsmirroring the existingseed-{aws,cf}-accounts.tsscripts), migration notes for operators coming from Atheme/Anope. - Acceptance Criteria:
- A new contributor can register a NickServ account and a ChanServ channel end-to-end following only the doc.
- The migration caveat (no Atheme/Anope compatibility) is called out up front.
- TDD Outline: N/A (documentation). Reviewed by walking the steps from a clean checkout.
8. Sequencing
Recommended landing order, respecting dependencies:
- Cheap IRCv3 wins first. TICKET-110 (
account-notify), TICKET-111 (msgid), TICKET-115 (sts), TICKET-121 (+Smode), TICKET-116 (draft/typing). All small, all compose with shipped work, all unblock better client UX. MONITOR+standard-replies. TICKET-113, TICKET-112.labeled-response. TICKET-114 — largest IRCv3 item, do last among the stable caps; touches every reducer that emits a reply.- Services foundation. TICKET-122 (port + in-memory store).
- NickServ core. TICKET-123, TICKET-124 — needed before ChanServ because every other service keys off the identified account.
- ChanServ. TICKET-125, TICKET-126.
- HostServ / OperServ / MemoServ. TICKET-127, TICKET-128, TICKET-129 (parallelizable).
- Persistent adapter backends. TICKET-130 (unblocks any production use of services).
- Draft caps that depend on services. TICKET-118 (
draft/read- marker), TICKET-119 (draft/pre-away). draft/multiline. TICKET-117 — independent, can land anywhere after the IRCv3 cheap wins.- Docs + ISUPPORT catch-up. TICKET-131, TICKET-120.
9. Risks
| Risk | Mitigation |
|---|---|
labeled-response plumbing touches every reducer |
Land BATCH wrapping first (already shipped); add label propagation as a ctx field and a single dispatch hook. Refactor incrementally per reducer. |
| Integrated services drift from user expectations (Atheme parity) | Document the decision up front (TICKET-131); keep command names/behaviour aligned where it's free. |
| Nick enforcement races (NICK + IDENTIFY interleaved) | All mutations through the single ServicesStore authority per account; the EnforceNick effect is emitted after reserveNick succeeds, so the nick is owned before enforcement fires. |
Services-backed caps (draft/read-marker, draft/pre-away) coupled to services launch |
Track as Phase 12 tickets with explicit Phase 13 dependencies; ship IRCv3 cheap wins independently. |
D1/DynamoDB cost from per-message msgid recording |
msgid is emitted on the live line without persisting; only MessageStore records (chathistory) hit storage. Same cost profile as today. |
draft/multiline byte budget abuse |
Hard cap enforced in the reducer (FAIL over-budget); per-connection flood control (TICKET-065 ✅) bounds the burst. |
10. Definition of done (per phase)
Phase 12 — IRCv3 extensions is done when:
- Every stable IRCv3 cap in §3.1 is advertised and exercised by the parametrized contract suite on the in-memory runtime.
- The draft caps in §3.2 are advertised under
draft/namespace and exercised; they can be flipped off via config without code changes. -
005 RPL_ISUPPORTadvertises every new token. - Client compat sweep (TICKET-050 ⬜) passes against ≥3 clients with the new caps negotiated.
Phase 13 — IRC services is done when:
- NickServ/ChanServ/HostServ/OperServ/MemoServ reducers ship at
100% coverage against
InMemoryServicesStore. - Both adapters ship persistent
ServicesStorebackends; the parametrized contract suite is green on both. - End-to-end: register → identify → SASL PLAIN → ChanServ REGISTER → KEEPTOPIC round-trip survives a deploy restart.
-
docs/Services.mdis complete and reviewer-validated.
11. Open questions
- Account name vs. nick. Should NickServ register an account (multiple nicks group to it, Atheme-style) or register a single nick? Default proposal: account-per-registration with nick grouping as a follow-up, matching SASL's existing account model.
- Memo size + retention. Default cap (256 bytes? 2 KiB?) and TTL (30 days?). Default proposal: 2 KiB per memo, 30-day TTL, both config-tunable.
+R/+Mnumerics. Server-specific519 ERR_ALLMUSTREGISTERvs. reusing473 ERR_INVITEONLYCHANsemantics. Default proposal: introduce the spec-recognized numerics where they exist, document server-specific ones inisupport.- AKILL scope. Network-wide (persisted) vs. per-listener (edge
only). Default proposal: persisted at the
ServicesStore, enforced in the admission path of every adapter — true network-wide on a single-server deployment.