Phase 1 of DIALECTIC-V2 — adds Fabric infrastructure for
system-broadcast channels with HF-status-aware delivery filtering.
New channel x_type 'announce':
- channels.entity.ts + channels.service.ts + realtime.gateway.ts
enum + union extended.
- computeDelivery() adds an 'announce' case: recipient with
presence='busy' → 'skip' (discarded silently); other presences →
'observer' (delivered, no wake). System-broadcast semantics —
agents proactively check their announce inbox when they're ready,
not interrupted out of band.
- messaging.controller POST guard: announce-type channels reject
posts that don't present x-fabric-system-key header matching
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty env = no system
caller is valid (closed-by-default).
New entity + module agent_presences:
- agent-presence.entity.ts: per-user (userId PK) status enum
(idle/on_call/busy/exhausted/offline/unknown), source tag, updatedAt
- agent-presence.service.ts: getStatus/getStatusMap (bulk for
delivery-time fanout) + setStatus (upsert)
- agent-presence.controller.ts: GET + PUT /agents/:userId/presence
- agent-presence.module.ts: TypeORM forFeature + wired into AppModule
- buildTypeOrmConfig() entities list extended
RealtimeGateway wiring:
- New optional field on the gateway (typed loosely to avoid
circular import). RealtimeModule.onModuleInit() assigns from the
injected AgentPresenceService — degrades gracefully (no busy-discard,
treat all as 'unknown') if presence wiring is ever removed.
- emitMessageCreated pre-loads presence per fanout only when xType is
'announce' (other xTypes bypass the lookup entirely).
Note: actual presence data writes come from Fabric.OpenclawPlugin's
presence-sync loop (separate commit on that submodule); without it,
all rows are 'unknown' and announce delivery falls through to the
default observer behavior (no busy filtering). System-only POST gate
is independent and works immediately.
See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md sections 7 + 10 Phase 1.
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>
C-1: messaging endpoints now enforce channel participation (public
channels open; private require channel_members). authorUserId is
forced to the authenticated user (no more author spoofing); edit/
delete require message-author ownership; history read gated too.
C-2: PUT /commands body strictly validated + size-capped via
SyncCommandsDto (kills catalog poisoning / DoS). Optional
FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY restricts the write to the
plugin when set; never weaker than before when unset.
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>
Channel.closed; POST /channels/:id/close (member-only); message/command
posts on closed channel -> 409 {error:channel_closed}; GET history still
allowed; listForUser carries closed.
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>
- parse <@user-id> outside backtick spans
- general: message with an at-list wakes only the at'd users (else all)
- report/triage/custom: mentions change nothing
- discuss/work: mention by current speaker pushes a sub-rotation frame
(atList = mentions - sender, intersected with channel members); single
linear pass (real/no-reply/force-proceed), then pop back to the saved
parent pointer (resumes at the pusher); nested frames supported
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>