refactor: centralize channel ingress access

This commit is contained in:
Peter Steinberger
2026-05-10 05:06:03 +01:00
parent 1725eebe62
commit a0fb7fb045
250 changed files with 11410 additions and 8161 deletions

View File

@@ -1,2 +1,2 @@
f303fc2fea17115736f9b7565ab2b6cfa07cfb46e5b3c804c55a945922f749db plugin-sdk-api-baseline.json
90a4a9538691ee1af4d1fcf10b3b0c1d161e835c03e87b09dc0ff0020d1ded7a plugin-sdk-api-baseline.jsonl
d0bbcee28603940444071492f65e293e708d2b437bdc6f0fb492fc41a0920760 plugin-sdk-api-baseline.json
f6a73cf469f279671ab00ff5a0e95eb0ff89d8a8b9652cf1ff9cd983aa69093d plugin-sdk-api-baseline.jsonl

View File

@@ -59,6 +59,22 @@
"source": "Channel message API",
"target": "频道消息 API"
},
{
"source": "Channel ingress API",
"target": "频道入口 API"
},
{
"source": "Channel access cleanup",
"target": "频道访问清理"
},
{
"source": "Ingress core shrink plan",
"target": "入口核心精简计划"
},
{
"source": "Ingress core shrink",
"target": "入口核心精简"
},
{
"source": "Talk mode",
"target": "Talk 模式"

View File

@@ -125,7 +125,26 @@ Access groups are available in shared message-channel authorization paths, inclu
- channel-specific per-room sender allowlists that use the same sender matching rules
- command authorization paths that reuse message-channel sender allowlists
Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes Discord, Google Chat, Nostr, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are designed to be channel-agnostic, so new message channels should support them by using the shared plugin SDK helpers instead of custom allowlist expansion.
Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes BlueBubbles, Discord, Feishu, Google Chat, iMessage, LINE, Mattermost, Microsoft Teams, Nextcloud Talk, Nostr, QQBot, Signal, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are designed to be channel-agnostic, so new message channels should support them by using the shared plugin SDK helpers instead of custom allowlist expansion.
## Plugin diagnostics
Plugin authors can inspect structured access-group state without expanding it back into a flat allowlist:
```typescript
import { resolveAccessGroupAllowFromState } from "openclaw/plugin-sdk/security-runtime";
const state = await resolveAccessGroupAllowFromState({
accessGroups: cfg.accessGroups,
allowFrom: channelConfig.allowFrom,
channel: "my-channel",
accountId: "default",
senderId,
isSenderAllowed,
});
```
The result reports referenced, matched, missing, unsupported, and failed groups. Use this when you need diagnostics or conformance tests. Use `expandAllowFromWithAccessGroups(...)` only for compatibility paths that still expect a flat `allowFrom` array.
## Discord channel audiences

View File

@@ -451,8 +451,8 @@ Example:
</Tab>
<Tab title="DM access groups">
Discord DMs can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
<Tab title="Access groups">
Discord DMs and text command authorization can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
Access group names are shared across message channels. Use `type: "message.senders"` for a static group whose members are expressed in each channel's normal `allowFrom` syntax, or `type: "discord.channelAudience"` when a Discord channel's current `ViewChannel` audience should define membership dynamically. Shared access-group behavior is documented here: [Access groups](/channels/access-groups).

View File

@@ -217,7 +217,7 @@ If SIP-disabled isn't acceptable for your threat model:
Allowlist field: `channels.imessage.allowFrom`.
Allowlist entries can be handles or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
Allowlist entries can be handles, static sender access groups (`accessGroup:<name>`), or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
</Tab>
@@ -230,6 +230,8 @@ If SIP-disabled isn't acceptable for your threat model:
Group sender allowlist: `channels.imessage.groupAllowFrom`.
`groupAllowFrom` entries can also reference static sender access groups (`accessGroup:<name>`).
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).

View File

@@ -139,6 +139,7 @@ Allowlists and policies:
- `channels.line.groupPolicy`: `allowlist | open | disabled`
- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups
- Per-group overrides: `channels.line.groups.<groupId>.allowFrom`
- Static sender access groups can be referenced from `allowFrom`, `groupAllowFrom`, and per-group `allowFrom` with `accessGroup:<name>`.
- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
LINE IDs are case-sensitive. Valid IDs look like:

View File

@@ -189,11 +189,13 @@ Notes:
- `openclaw pairing list mattermost`
- `openclaw pairing approve mattermost <CODE>`
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
- `channels.mattermost.allowFrom` accepts `accessGroup:<name>` entries. See [Access groups](/channels/access-groups).
## Channels (groups)
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended).
- `channels.mattermost.groupAllowFrom` accepts `accessGroup:<name>` entries. See [Access groups](/channels/access-groups).
- Per-channel mention overrides live under `channels.mattermost.groups.<channelId>.requireMention` or `channels.mattermost.groups["*"].requireMention` for a default.
- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`.
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).

View File

@@ -146,14 +146,14 @@ Disable with:
**DM access**
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `channels.msteams.allowFrom` should use stable AAD object IDs.
- `channels.msteams.allowFrom` should use stable AAD object IDs or static sender access groups such as `accessGroup:core-team`.
- Do not rely on UPN/display-name matching for allowlists - they can change. OpenClaw disables direct name matching by default; opt in explicitly with `channels.msteams.dangerouslyAllowNameMatching: true`.
- The wizard can resolve names to IDs via Microsoft Graph when credentials allow.
**Group access**
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
- `channels.msteams.groupAllowFrom` controls which senders or static sender access groups can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
- Set `groupPolicy: "open"` to allow any member (still mention-gated by default).
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
@@ -164,7 +164,7 @@ Example:
channels: {
msteams: {
groupPolicy: "allowlist",
groupAllowFrom: ["user@org.com"],
groupAllowFrom: ["00000000-0000-0000-0000-000000000000", "accessGroup:core-team"],
},
},
}

View File

@@ -157,6 +157,7 @@ Provider options:
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
- Static sender access groups can be referenced from `allowFrom` and `groupAllowFrom` with `accessGroup:<name>`.
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).

View File

@@ -44,7 +44,14 @@ Account keys:
- `botUserId` - Matrix-style bot user id used in target grammar.
- `botDisplayName` - display name for outbound messages.
- `pollTimeoutMs` - long-poll wait window. Integer between 100 and 30000.
- `allowFrom` - sender allowlist (user ids or `"*"`).
- `allowFrom` - sender allowlist (user ids or `"*"`). Direct messages and
allowlisted group policy both use these synthetic sender ids.
- `groupPolicy` - shared-room policy: `"open"` (default), `"allowlist"`, or
`"disabled"`.
- `groupAllowFrom` - optional shared-room sender allowlist. When omitted under
`"allowlist"`, QA Channel falls back to `allowFrom`.
- `groups.<room>.requireMention` - require a bot mention before replying in a
specific group/channel room. `groups."*"` sets the default.
- `defaultTo` - fallback target when none is supplied.
- `actions.messages` / `actions.reactions` / `actions.search` / `actions.threads` - per-action tool gating.

View File

@@ -81,7 +81,7 @@ openclaw directory groups list --channel zalouser --query "work"
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
`channels.zalouser.allowFrom` should use stable Zalo user IDs. During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup.
`channels.zalouser.allowFrom` should use stable Zalo user IDs. It can also reference static sender access groups (`accessGroup:<name>`). During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup.
If a raw name remains in config, startup resolves it only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. Without that opt-in, runtime sender checks are ID-only and raw names are ignored for authorization.
@@ -96,7 +96,7 @@ Approve via:
- Restrict to an allowlist with:
- `channels.zalouser.groupPolicy = "allowlist"`
- `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled)
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot)
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot; static sender access groups can be referenced with `accessGroup:<name>`)
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
- The configure wizard can prompt for group allowlists.
- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled.

View File

@@ -0,0 +1,137 @@
---
summary: "Experimental channel ingress API for inbound message authorization"
read_when:
- Building or migrating a messaging channel plugin
- Changing DM or group allowlists, route gates, command auth, event auth, or mention activation
- Reviewing channel ingress redaction or SDK compatibility boundaries
title: "Channel ingress API"
sidebarTitle: "Channel Ingress"
---
# Channel ingress API
Channel ingress is the experimental access-control boundary for inbound channel
events. Use `openclaw/plugin-sdk/channel-ingress-runtime` for receive paths.
The older `openclaw/plugin-sdk/channel-ingress` subpath stays exported as a
deprecated compatibility facade for third-party plugins.
Plugins own platform facts and side effects. Core owns generic policy: DM/group
allowlists, pairing-store DM entries, route gates, command gates, event auth,
mention activation, redacted diagnostics, and admission.
## Runtime Resolver
```ts
import {
defineStableChannelIngressIdentity,
resolveChannelMessageIngress,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
const identity = defineStableChannelIngressIdentity({
key: "platform-user-id",
normalize: normalizePlatformUserId,
sensitivity: "pii",
});
const result = await resolveChannelMessageIngress({
channelId: "my-channel",
accountId,
identity,
subject: { stableId: platformUserId },
conversation: { kind: isGroup ? "group" : "direct", id: conversationId },
event: { kind: "message", authMode: "inbound", mayPair: !isGroup },
policy: {
dmPolicy: config.dmPolicy,
groupPolicy: config.groupPolicy,
groupAllowFromFallbackToAllowFrom: true,
},
allowFrom: config.allowFrom,
groupAllowFrom: config.groupAllowFrom,
accessGroups: cfg.accessGroups,
route,
readStoreAllowFrom,
command: hasControlCommand ? { allowTextCommands: true, hasControlCommand } : undefined,
});
```
Do not precompute effective allowlists, command owners, or command groups. The
resolver derives them from raw allowlists, store callbacks, route descriptors,
access groups, policy, and conversation kind.
## Result
Bundled plugins should consume modern projections directly:
- `ingress`: ordered gate decision and admission
- `senderAccess`: sender/conversation authorization only
- `routeAccess`: route and route-sender projection
- `commandAccess`: command authorization; false when no command gate ran
- `activationAccess`: mention/activation result
Event authorization remains available on the ordered `ingress.graph` and the
decisive `ingress.reasonCode`; no separate event projection is emitted.
Deprecated third-party SDK helpers may rebuild older shapes internally. New
bundled receive paths should not translate modern results back into local DTOs.
## Access Groups
`accessGroup:<name>` entries stay redacted. Core resolves static
`message.senders` groups itself and calls `resolveAccessGroupMembership` only
for dynamic groups that require a platform lookup. Missing, unsupported, and
failed groups fail closed.
## Event Modes
| `authMode` | Meaning |
| ---------------- | ------------------------------------------------ |
| `inbound` | normal inbound sender gates |
| `command` | command gates for callbacks or scoped buttons |
| `origin-subject` | actor must match the original message subject |
| `route-only` | route gates only for route-scoped trusted events |
| `none` | plugin-owned internal events bypass shared auth |
Use `mayPair: false` for reactions, buttons, callbacks, and native commands.
## Routes And Activation
Use route descriptors for room, topic, guild, thread, or nested route policy:
```ts
route: {
id: "room",
allowed: roomAllowed,
enabled: roomEnabled,
senderPolicy: "replace",
senderAllowFrom: roomAllowFrom,
blockReason: "room_sender_not_allowlisted",
}
```
Use `channelIngressRoutes(...)` when a plugin has several optional route
descriptors; it filters disabled branches while keeping route facts generic and
ordered by each descriptor's `precedence`.
Mention gating is an activation gate. A mention miss returns
`admission: "skip"` so the turn kernel does not process an observe-only turn.
Most channels should leave activation after sender and command gates. Public
chat surfaces that must quiet non-mentioned traffic before sender allowlist
noise can opt into `activation.order: "before-sender"` when text-command
bypass is disabled. Channels with implicit activation, such as replies in bot
threads, can pass `activation.allowedImplicitMentionKinds`; the projected
`activationAccess.shouldBypassMention` then reports when command or implicit
activation bypassed an explicit mention.
## Redaction
Raw sender values and raw allowlist entries are resolver input only. They must
not appear in resolved state, decisions, diagnostics, snapshots, or
compatibility facts. Use opaque subject ids, entry ids, route ids, and
diagnostic ids.
## Verification
```bash
pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts
pnpm plugin-sdk:api:check
```

View File

@@ -74,6 +74,16 @@ remain available for compatibility dispatchers. Do not use those names for new
channel code; new plugins should start with the `message` adapter, receipts, and
receive/send lifecycle helpers on `openclaw/plugin-sdk/channel-message`.
Channels migrating inbound authorization can use the experimental
`openclaw/plugin-sdk/channel-ingress-runtime` subpath from runtime receive
paths. The subpath keeps platform lookup and side effects in the plugin, while
sharing allowlist state resolution, route/sender/command/event/activation
decisions, redacted diagnostics, and turn-admission mapping. Keep plugin
identity normalization in the descriptor you pass to the resolver; do not
serialize raw match values from the resolved state or decision. See
[Channel ingress API](/plugins/sdk-channel-ingress) for the API design,
ownership boundary, and test expectations.
If your channel supports typing indicators outside inbound replies, expose
`heartbeat.sendTyping(...)` on the channel plugin. Core calls it with the
resolved heartbeat delivery target before the heartbeat model run starts and

View File

@@ -64,6 +64,7 @@ The runtime exposes three preferred entry points so adapters can opt in at the l
```typescript
runtime.channel.turn.run(...) // adapter-driven full pipeline
runtime.channel.turn.runAssembled(...) // already-built context + delivery adapter
runtime.channel.turn.runPrepared(...) // channel owns dispatch; kernel runs record + finalize
runtime.channel.turn.buildContext(...) // pure facts to FinalizedMsgContext mapping
```
@@ -72,7 +73,7 @@ Two older runtime helpers remain available for Plugin SDK compatibility:
```typescript
runtime.channel.turn.runResolved(...) // deprecated compatibility alias; prefer run
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer run or runPrepared
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer runAssembled
```
### run
@@ -114,6 +115,41 @@ await runtime.channel.turn.run({
`run` is the right shape when the channel has small adapter logic and benefits from owning the lifecycle through hooks.
### runAssembled
Use when the channel has already resolved routing, built a `FinalizedMsgContext`,
and only needs the shared record, reply-pipeline, dispatch, and finalize
ordering. This is the preferred shape for simple bundled inbound paths that
would otherwise repeat `createChannelMessageReplyPipeline(...)` and
`runPrepared(...)` boilerplate.
```typescript
await runtime.channel.turn.runAssembled({
cfg,
channel: "irc",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await sendPlatformReply(payload);
},
onError: (err, info) => {
runtime.error?.(`reply ${info.kind} failed: ${String(err)}`);
},
},
});
```
Choose `runAssembled` over `runPrepared` when the only channel-owned dispatch
behavior is final payload delivery plus optional typing, reply options, durable
delivery, or error logging.
### runPrepared
Use when the channel has a complex local dispatcher with previews, retries, edits, or thread bootstrap that must stay channel-owned. The kernel still records the inbound session before dispatch and surfaces a uniform `DispatchedChannelTurnResult`.

View File

@@ -56,6 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/account-id` | `DEFAULT_ACCOUNT_ID`, account-id normalization helpers |
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
| `plugin-sdk/access-groups` | Access-group allowlist parsing and redacted group diagnostics helpers |
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Legacy reply pipeline helpers. New channel reply pipeline code should use `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode` from `plugin-sdk/channel-message`. |
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
@@ -65,6 +66,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-ingress` | Deprecated low-level channel ingress compatibility facade. New receive paths should use `plugin-sdk/channel-ingress-runtime`. |
| `plugin-sdk/channel-ingress-runtime` | Experimental high-level channel ingress runtime resolver and route fact builders for migrated channel receive paths. Prefer this over assembling effective allowlists, command allowlists, and legacy projections in each plugin. See [Channel ingress API](/plugins/sdk-channel-ingress). |
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, and legacy draft stream lifecycle helpers. New preview finalization code should use `plugin-sdk/channel-message`. |
| `plugin-sdk/channel-message` | Cheap message lifecycle contract helpers such as `defineChannelMessageAdapter`, `createChannelMessageAdapterFromOutbound`, `createChannelMessageReplyPipeline`, `createReplyPrefixContext`, `resolveChannelMessageSourceReplyDeliveryMode`, durable-final capability derivation, capability proof helpers for send/receipt/side-effect capabilities, `MessageReceiveContext`, receive ack policy proofs, `defineFinalizableLivePreviewAdapter`, `deliverWithFinalizableLivePreviewAdapter`, live-preview and live-finalizer capability proofs, durable recovery state, `RenderedMessageBatch`, message receipt types, and receipt id helpers. See [Channel message API](/plugins/sdk-channel-message). Legacy reply-dispatch facades are deprecated compatibility only. |
| `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, and `withDurableMessageSendContext`. Deprecated reply-dispatch bridges remain importable for compatibility dispatchers only. Use from monitor/send runtime modules, not hot plugin bootstrap files. |

9
docs/refactor/access.md Normal file
View File

@@ -0,0 +1,9 @@
---
summary: "Redirect to /refactor/ingress-core"
read_when:
- Changing channel ingress, sender access, command authorization, or access-group handling
title: "Channel access cleanup"
sidebarTitle: "Channel access cleanup"
---
The channel access cleanup plan now lives in [Ingress core deletion plan](/refactor/ingress-core).

View File

@@ -0,0 +1,341 @@
---
summary: "Deletion-first plan for moving repeated channel ingress glue into core."
read_when:
- Auditing why the channel ingress refactor added too much code
- Moving route, command, event, activation, or access-group policy from bundled plugins into core
- Reviewing whether a channel ingress helper actually deletes bundled plugin code
title: "Ingress core deletion plan"
sidebarTitle: "Ingress core deletion"
---
# Ingress core deletion plan
The ingress refactor is not healthy while it adds thousands of net lines. Core
centralization only counts when bundled plugin production code gets smaller and
old third-party SDK compatibility is quarantined to SDK/core shims.
Desired runtime shape:
```text
bundled plugin event
-> extract platform facts locally
-> resolve shared ingress once when facts are available
-> branch on generic ingress projections/outcomes
-> perform platform side effects locally
old third-party helper
-> SDK compatibility shim
-> shared ingress-compatible projection where possible
-> old return shape preserved
```
Bundled plugins should not translate ingress back into local `AccessResult`,
`GroupAccessDecision`, `CommandAuthDecision`, `DmCommandAccess`, or
`{ allowed, reasonCode }` shapes unless that type is public plugin API.
## Budget
Measured against the PR merge-base with `origin/main`, including untracked
files.
```text
merge-base 1671e7532adb
current:
core production +3,922 / -546 = +3,376
docs +601 / -17 = +584
other +145 / -2 = +143
plugin production +4,148 / -5,388 = -1,240
tests +2,326 / -2,414 = -88
total +11,142 / -8,367 = +2,775
required:
plugin production <= -1,500
core production <= +1,500, or paid for by larger plugin deletion
tests <= +1,000
total <= +2,000
stretch:
plugin production <= -2,500
core production <= +1,200
total <= 0
```
Minimum remaining cleanup:
```text
plugin production needs 260 more net deleted lines
total needs 775 more net deleted lines
core production still +1,876 over standalone budget, unless paid down by plugin deletion
```
Comment-only deletion does not count as cleanup. The previous budget pass was
too generous because it included restored QQBot explanatory comments; this
document tracks executable/docs/test code movement only.
Re-measure after each cleanup wave:
```sh
base=$(git merge-base HEAD origin/main)
git diff --shortstat "$base"
git diff --numstat "$base" -- src/channels/message-access src/plugin-sdk extensions | sort -nr -k1 | head -n 80
pnpm lint:extensions:no-deprecated-channel-access
```
## Diagnosis
The first pass added the shared ingress kernel, then left too much plugin-local
authorization beside it:
```text
platform facts
-> shared ingress state and decision
-> plugin-local DTO or legacy projection
-> plugin-local if/else ladder
```
That duplicates the model. Core production grew by about 3,376 lines, while
bundled plugin production is 1,240 lines smaller. That is better than the first
pass, but it is not inside the minimum budget. The fix remains deletion-first:
- delete plugin DTOs that only rename ingress fields
- delete tests that only assert wrapper shape
- add core helpers only when the same patch deletes bundled plugin code
- keep old SDK compatibility in SDK/core shims only
- repack core after wrapper deletion exposes the stable shape
## Hotspots
Positive bundled production files that still need to shrink:
```text
extensions/telegram/src/ingress.ts +126
extensions/discord/src/monitor/dm-command-auth.ts +101
extensions/signal/src/monitor/access-policy.ts +92
extensions/feishu/src/policy.ts +85
extensions/slack/src/monitor/auth.ts +64
extensions/googlechat/src/monitor-access.ts +59
extensions/nextcloud-talk/src/inbound.ts +51
extensions/matrix/src/matrix/monitor/access-state.ts +49
extensions/irc/src/inbound.ts +44
extensions/imessage/src/monitor/inbound-processing.ts +36
extensions/qa-channel/src/inbound.ts +34
extensions/qqbot/src/bridge/sdk-adapter.ts +33
extensions/tlon/src/monitor/utils.ts +30
extensions/twitch/src/access-control.ts +22
extensions/qqbot/src/engine/commands/slash-command-handler.ts +20
extensions/telegram/src/bot-handlers.runtime.ts +19
```
The branch is not inside the minimum budget yet. The remaining review-relevant
work should delete repeated authorization flow, turn scaffolding, or wrapper
tests before adding another core abstraction.
## Current Code Read
The healthy core seam already exists in `src/channels/message-access/runtime.ts`:
it owns identity adapters, effective allowlists, pairing-store reads, route
descriptors, command/event presets, access groups, and the final resolved
`ResolvedChannelMessageIngress` projection.
The remaining growth is mostly plugin glue layered on top of that seam:
- `extensions/telegram/src/ingress.ts` wraps core decisions in Telegram-specific
command/event helpers, then call sites still pass precomputed normalized
allowlists and owner lists.
- `extensions/discord/src/monitor/dm-command-auth.ts`,
`extensions/feishu/src/policy.ts`, `extensions/googlechat/src/monitor-access.ts`,
and `extensions/matrix/src/matrix/monitor/access-state.ts` still keep
local policy DTOs or legacy decision names beside ingress.
- `extensions/signal/src/monitor/access-policy.ts` correctly keeps Signal
identity normalization and pairing replies local, but still has a wrapper
seam that should collapse into direct ingress consumption.
- `extensions/nextcloud-talk/src/inbound.ts`, `extensions/irc/src/inbound.ts`,
`extensions/qa-channel/src/inbound.ts`, `extensions/zalo/src/monitor.ts`, and
`extensions/zalouser/src/monitor.ts` still repeat route/envelope/turn
assembly that can move to shared turn helpers outside the ingress kernel.
Conclusion: moving more code into core is only useful if it deletes these
plugin wrapper layers in the same patch. Adding another abstraction while
leaving wrapper returns in place repeats the mistake.
## Boundary
Core owns generic policy:
- allowlist normalization and matching
- access-group expansion and diagnostics
- pairing-store DM allowlist reads
- route, sender, command, event, and activation gates
- admission mapping: dispatch, drop, skip, observe, pairing
- redacted state, decisions, diagnostics, and SDK compatibility projections
- reusable generic descriptors for identity, route, command, event, activation,
and outcomes
Plugins own transport facts and side effects:
- webhook/socket/request authenticity
- platform identity extraction and API lookups
- channel-specific policy defaults
- pairing challenge delivery, replies, acks, reactions, typing, media, history,
setup, doctor, status, logs, and user-facing copy
Core must stay channel-agnostic: no Discord, Slack, Telegram, Matrix, room,
guild, space, API client, or plugin-specific default in
`src/channels/message-access`.
## Acceptance Rule
Every new core helper must delete bundled plugin production code immediately.
```text
one bundled caller reject; keep plugin-local
two bundled callers accept only if plugin production LOC drops
three or more callers plugin deletion must be at least 2x new core LOC
compatibility-only helper SDK/core shim only; never bundled hot paths
```
Stop and redesign if:
- plugin production LOC increases
- tests grow faster than production shrinks
- a bundled hot path returns a DTO that only renames `ResolvedChannelMessageIngress`
- a core helper needs a channel id, platform object, API client, or
channel-specific default
## Work Packages
1. Freeze the budget.
Put LOC in the PR, keep deprecated-ingress lint green, and include before/after
LOC in cleanup commits.
2. Delete thin DTO seams.
Replace plugin-local wrapper returns with `ResolvedChannelMessageIngress`,
`senderAccess`, `commandAccess`, `routeAccess`, or `ingress` directly. Start
with QQBot, Telegram, Slack, Discord, Signal, Feishu, Matrix, iMessage, and
Tlon. Delete wrapper-shape tests; keep behavior tests.
3. Add outcome classification only with deletions.
A generic classifier may expose `dispatch`, `pairing-required`,
`skip-activation`, `drop-command`, `drop-route`, `drop-sender`, and
`drop-ingress`. It must derive from the decision graph, not reason strings,
and migrate at least three plugins in the same patch.
4. Add route descriptor builders only with deletions.
Generic route target and route sender helpers are acceptable only if they
immediately shrink route-heavy plugins: Google Chat, IRC, Microsoft Teams,
Nextcloud Talk, Mattermost, Slack, Zalo, and Zalo Personal.
5. Add command/event presets only with deletions.
Centralize text-command, native-command, callback, and origin-subject shapes.
Command consumers must default to unauthorized when no command gate ran;
events must not start pairing.
6. Add identity presets only where they remove boilerplate.
Stable-id, stable-id-plus-aliases, phone/e164, and multi-identifier helpers
are allowed when raw values enter only adapter input and redacted state keeps
opaque ids/counts.
7. Share authorized turn assembly.
Outside the ingress kernel, remove repeated route/envelope/context/reply
scaffolding from QA Channel, IRC, Nextcloud Talk, Zalo, and Zalo Personal.
Core may own route/session/envelope/dispatch sequencing; plugins keep
delivery and channel-specific context.
8. Quarantine compatibility.
Deprecated SDK helpers stay source-compatible, but bundled hot paths must not
import deprecated ingress or command-auth facades. Compatibility tests should
use fake third-party plugins, not bundled-plugin internals.
9. Repack core.
After wrapper deletion, collapse one-use modules, remove unused exports, move
compatibility projection out of hot paths, and keep focused tests for identity,
route, command/event, activation, access groups, and compatibility shims.
## Deletion Waves
Run these in order. Each wave must lower bundled production LOC.
1. Wrapper collapse, expected plugin delta: -400 to -600.
Replace plugin-local `resolveXAccess`, `resolveXCommandAccess`, and
`accessFromIngress` result types with direct reads from
`ResolvedChannelMessageIngress`. First targets: Discord DM command auth,
Feishu policy, Matrix access state, Telegram ingress, Signal access policy,
QQBot SDK adapter.
2. Shared outcome helpers, expected plugin delta: -200 to -350.
Add one generic classifier only if it deletes repeated
`shouldBlockControlCommand`, pairing, activation skip, route block, and sender
block ladders across at least three plugins.
3. Route descriptor builders, expected plugin delta: -200 to -350.
Move repeated route target and route sender descriptor assembly into core
helpers. First targets: Google Chat, IRC, Microsoft Teams, Nextcloud Talk,
Mattermost, Slack, Zalo, Zalo Personal.
4. Turn assembly sharing, expected plugin delta: -250 to -450.
Use common route/session/envelope/dispatch sequencing for simple inbound
plugins. First targets: QA Channel, IRC, Nextcloud Talk, Zalo, Zalo Personal.
5. Core repack, expected core delta: -300 to -700.
After plugins consume runtime projections directly, delete one-use modules,
merge tiny files back into `runtime.ts` or focused siblings, and keep SDK
compatibility files separate from bundled hot paths.
6. Test pruning, expected test delta: -300 to -600.
Delete tests that only assert removed wrapper shapes. Keep behavior tests for
command denial, group fallback, origin-subject matching, activation skip,
access groups, pairing, and redaction.
Expected minimum landing shape after these waves:
```text
plugin production <= -1,500
core production about +1,800 to +2,200 before final repack
tests <= +500
total <= +2,000
```
## Do Not Move
Do not move platform config defaults, setup UX, doctor/fix copy, API lookups,
Slack owner-presence checks, Matrix alias/verification handling, Telegram
callback parsing, command syntax parsing, native command registration, reaction
payload parsing, pairing replies, command replies, acks, typing, media, history,
or logs.
## Verification
Targeted local loop:
```sh
pnpm lint:extensions:no-deprecated-channel-access
pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts src/plugin-sdk/access-groups.test.ts
pnpm test extensions/<changed-plugin>/src/...
pnpm plugin-sdk:api:check
pnpm config:docs:check
pnpm check:docs
git diff --check
```
Use Testbox for broad changed gates/full-suite proof once the LOC trend is
inside budget.
Each work package records:
- before/after LOC by category
- deleted plugin wrappers
- new core helper LOC, if any
- targeted tests run
- remaining hotspot list
## Exit Criteria
- bundled production imports no deprecated channel-access or command-auth facades
- compatibility code is isolated to SDK/core seams
- bundled plugins consume ingress projections or generic outcomes directly
- plugin production LOC is at least 1,500 net negative against `origin/main`
- core production LOC is <= +1,500, or any excess is paid for while total stays
<= +2,000
- representative tests cover redaction, route, command/event, activation,
access-group, and channel-specific fallback behavior