Pairs with Dialectic.Backend@5cf4302 which removes the backend-driven
broadcaster that was the only consumer of the x-fabric-system-key
header path. Backend cleanup is complete on the consumer side; this
removes the producer-side surface.
Removed:
- ApiKeyGuard: x-fabric-system-key bypass branch (sysExpected /
sysProvided / req.isSystem flag) — only Bearer flow remains.
- messaging.controller.create(): the entire 'if (req.isSystem)'
branch including the SYSTEM_USER_ID='00000000-...-0000' sentinel
persistence path.
- messaging.controller.create(): the 'if (xType === announce) throw
announce_system_only' gate. Announce channels are now ordinary
channels — any participant can POST. Use case: agents post one-off
recruitment broadcasts via fabric-send-message (e.g. dialectic
'come participate in topic X' messages).
- cli/gen-system-api-key.ts: deleted (was the generator for the env
that's no longer read).
Kept:
- channel.purpose field + PATCH /api/channels/:id (member auth for
setting purpose — agents use this to label channels for
fabric-channel-list discoverability).
- cli/print-commands-sync-key.ts (separate key, separate lifecycle).
- GuildRole.isSystem flag (unrelated — system-role permission gate).
Three coupled changes that let Dialectic.Backend (and future system
broadcasters) post to announce channels without needing a Fabric user
bearer.
1. ApiKeyGuard: when x-fabric-system-key matches
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env, skip the Bearer requirement
and set req.isSystem=true. Pre-Bearer system bypass; no per-user
session token needed. Empty env -> bypass disabled (closed by default).
2. messaging.controller POST /channels/:id/messages: when req.isSystem,
skip assertParticipant + fetch channel directly. Enforce xType=announce
(system key only writes to announce channels - never to regular chats).
Persist with sentinel author 00000000-0000-0000-0000-000000000000.
Emit message.created + realtime.emitMessageCreated with xType=announce
so the Phase 1 busy-discard logic kicks in for recipients.
3. New cli: src/cli/gen-system-api-key.ts. Generates a random 32-byte
hex key (same shape as agent + admin keys) and prints it. Does NOT
store - operator pastes into compose env and restarts guild. Pattern
mirrors the existing print-commands-sync-key.ts.
Removes the need for a FABRIC_BOT_BEARER_TOKEN concept entirely - the
system key alone is sufficient. announce-channel posts by regular
authenticated users (who happen to know channel id but no system key)
are now 403 announce_system_only.
Triage channels now compute a 3-state delivery decision per recipient
(wake / observer / skip) instead of the binary wakeup flag, and route
according to:
1. author never gets back their own message → skip
2. wake_mapping member (on-duty) → wake
3. mention (NEW: was 'skip' for triage before) → wake
4. Center-scoped admin (at most 1) → observer
5. anyone else → skip
(was 'deliver wake=false')
Skipping means the websocket emit is omitted entirely — the recipient's
openclaw plugin never sees the message and the agent's session stays
free of background noise. Observer means delivered with wakeup=false
(silent UI / no model dispatch on the plugin side).
## What this PR ships
### realtime/realtime.gateway.ts
- new `computeDelivery()` returns DeliveryDecision = 'wake'|'observer'|'skip'
- old `computeWakeup()` kept as a deprecated wrapper for callers that
still want the boolean answer (treats observer + skip as false)
- `emitMessageCreated` accepts `adminUserId?: string|null` and now
short-circuits on 'skip' (no socket emit at all)
- general kept its current behavior; custom kept its current behavior
(members not in wake_mapping become observer instead of `wake=false`)
— the user-visible bit is just that the response field is the same
`wakeup: boolean`; the explicit 'skip' is new for triage
### common/center-auth.ts
- `fetchAdminEmail()` calls GET `${center}/auth/admin-email` with the
existing x-api-key (same auth as introspect/resolve-names). Returns
`{email, userId}` or `null` on either "no admin" or any error
### common/admin-cache.service.ts (NEW)
- `AdminCacheService` — in-memory cache, 1-day TTL, lazy refresh.
`get(force=true)` bypasses TTL for cli-triggered refresh
- exposed by MessagingModule
### messaging/messaging.controller.ts
- non-rotating branch threads `adminUserId` into emitMessageCreated
### cli/admin-refresh.ts (NEW)
- `node dist/cli/admin-refresh.js` — force-refresh cache and print
before/after JSON. Use after a Center `user set-admin` so triage
delivery picks up the new admin without waiting for 24h TTL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
package.json type=module, tsconfig module/moduleResolution=NodeNext,
target es2022, explicit .js on all relative imports. Center: jsonwebtoken
& bcryptjs switched to default imports (ESM/CJS interop). Verified:
builds, boots, full auth + plugin round-trip work under ESM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before persist/parse, resolve <@user.name:NAME> (outside backticks) via
Center and rewrite to <@userId>; unresolved tokens left as-is. Translated
ids then flow into the existing mention/wakeup/sub-frame logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>