# Fabric — Test Points Reference checklist of everything that should be verified across the stack. Organized by component. Re-run the **whole list** after any infra-level change (ESM migration, dependency bump, Docker/compose change) since those can regress any path. Legend: **Verified** = exercised on the live local stack at least once. Local stack = `docker compose -f docker-compose.local.yml` (Center :7001, Guild1 :7002 = `test-guild1`, Guild2 :7003 = `test-guild2`, Frontend :8088). --- ## 1. Fabric.Backend.Center (auth / identity) | # | Test point | How to verify | Expected | |---|---|---|---| | C1 | `user create` CLI | `node dist/cli.js user create --email --password

` | `{ok:true,user}` | | C2 | `user apikey` CLI | `node dist/cli.js user apikey --email [--label l]` | `{ok:true,apiKey:"fak_…"}` once | | C3 | `POST /auth/login` | valid creds | session: accessToken, refreshToken, user{id,email,name}, guilds, guildAccessTokens | | C4 | login email validation | email with 1-char TLD (`a@b.c`) | 400 (`@IsEmail`) — login & register both reject | | C5 | `POST /auth/refresh` / `logout` | with refreshToken | new tokens / `{status:ok}` | | C6 | `GET /auth/me` | bearer token | `{id,email,name}` | | C7 | `PATCH /auth/me {name}` | bearer; then re-login | name updated & persists; empty → 4xx; default name = email | | C8 | `POST /auth/agent/login {apiKey}` | valid key | same shape as login (guilds+tokens) | | C9 | agent/login bad key | `{apiKey:"nope"}` | 401 | | C10 | api-key-guard exemptions | login, agent/login, refresh, logout, GET/PATCH me, me/guilds, join, members | reachable without `x-api-key` | | C11 | `POST /auth/resolve-names {guildNodeId,names}` | api-key auth (guild node key) | name/email → userId, scoped to guild members; unknown omitted | | C12 | `POST /auth/me/guilds/join` | bearer | membership added | | C13 | `GET /auth/me/guilds` | bearer | guilds + fresh guildAccessTokens | | C14 | `GET /auth/guilds/:nodeId/members` | member bearer | members incl. `name` | | C15 | guild node register | CLI / `POST /api/nodes/register` (localhost) | node + apiKey | | C16 | `user.name` defaults to email | new user | login/members responses carry name=email until changed | ## 2. Fabric.Backend.Guild — channels | # | Test point | Expected | |---|---|---| | G1 | create channel: `xType` required | missing → 400 | | G2 | `xType` enum | not in {general,work,report,discuss,triage,custom} → 400 | | G3 | creator auto-added as channel member | always in `channel_members` | | G4 | `memberUserIds` added | listed users become members | | G5 | triage `onDuty` required | missing → 400 | | G6 | triage onDuty | auto-added member + 1 `wake_mapping` row | | G7 | custom `listeners` | one `wake_mapping` row per listener (optional/empty ok) | | G8 | `POST /channels/:id/close` | member (or public) → ok; non-member non-public → 403 | | G9 | post to closed channel | 409 `{error:"channel_closed"}` (normal + command) | | G10 | closed channel history | `GET …/messages` still 200 | | G11 | `listForUser` carries `closed`/`isPublic`/`isMember` | present & correct | | G12 | `POST /channels/:id/join` | public → member; non-public non-member → 403; idempotent | | G13 | `POST /channels/:id/leave` | removes `channel_members` + `wake_mapping` rows + turn order entry | | G14 | `GET /channels/:id/members` | explicit member userIds | | G15 | `listForUser` visibility | public OR explicit member only | | G16 | create discuss/work with `bypassUserIds` | order = members − bypass (∩ members), sorted; bypass stored; disjoint partition | | G17 | `POST /channels/:id/bypass {userId}` | any channel member actor; target must be a member; discuss/work only (else 400); moves target order→bypass | | G18 | `GET /channels/:id/members` carries `bypass` | each row `{userId, bypass:boolean}` from turn state | ## 3. Fabric.Backend.Guild — messaging / wakeup | # | Test point | Expected | |---|---|---| | W1 | one message-id; metadata at push only | `message.created` socket payload = view + `channelId` + per-recipient `wakeup` | | W2 | author rule (precedence) | author's own message → `wakeup=false` (overrides all) | | W3 | general (no mention) | all recipients true except author | | W4 | general + `<@id>` | only at'd (mentioned − author) true; others false | | W5 | report | all false | | W6 | triage / custom | only `wake_mapping` users true | | W7 | mention parse | `<@id>` counts only outside backtick spans | | W8 | name-mention translation | `<@user.name:NAME>` → `<@userId>` (Center resolve), outside backticks | | W9 | name-mention unresolved | left literal | | W10 | name-mention in backticks | left literal (not translated) | ## 4. Fabric.Backend.Guild — slash commands | # | Test point | Expected | |---|---|---| | S1 | command registry | only registered (`/no-reply`,`/force-proceed`) intercepted | | S2 | unknown `/x` (e.g. `/etc/passwd`) | delivered as a normal message | | S3 | command never delivered | no message row; response `{status:command}` | | S4 | `/no-reply` outside discuss/work | swallowed, no effect | ## 5. Fabric.Backend.Guild — discuss/work turn engine | # | Test point | Expected | |---|---|---| | T1 | init state | `currentSpeaker = NULL`, order = members sorted by id, frames `[]` | | T2 | single-member channel | `currentSpeaker` stays `NULL` forever | | T3 | activation (from NULL) | speaker X → moved to order[0], `currentSpeaker = order[1]` | | T4 | advance (current speaker normal) | `currentSpeaker → successor`; wakeup only successor | | T5 | queue-jump (non-current normal) | no advance; all wakeup false; resets cross-round no-reply streak | | T6 | queue-jump `/no-reply` | swallowed; does NOT reset streak | | T7 | current `/no-reply` | guild `/ack` emitted; advance; streak += sender | | T8 | all-members consecutive `/no-reply` (cross-round) | pause → `currentSpeaker=NULL`; resumes on next proactive normal msg | | T9 | end-of-round shuffle | trailing `/no-reply` run → tail; anchor = last "prev not /nr & self /nr"; head shuffled; head[0] ≠ last normal speaker (D) | | T10 | shuffle infeasible (head empty / only D) | pause instead of shuffle | | T11 | `/force-proceed` | skip current (not recorded, streak untouched), advance; at order[-1] triggers shuffle | | T12 | member join mid-rotation | appended to order tail | | T13 | member leave | removed from order/streak/frames; if current → successor takes over | | T14 | mention sub-frame push | current-speaker mention → push `{order:atList, idx:0}`; atList = mentions−sender ∩ members; effective current = atList[0] | | T15 | sub-frame single pass | each member acts once (real / `/no-reply` / `/force-proceed`) → pop | | T16 | sub-frame pop | parent restored at saved pointer (the pusher); root `current_speaker` column preserved during sub-frame | | T17 | nested sub-frames | mention inside a sub-frame pushes deeper; pops in order | | T18 | backtick mention | `` `<@id>` `` does NOT push a sub-frame | | T19 | `/ack` message | author=`guild`, content `/ack`, persisted (own message-id+seq), wakeup=true only for new currentSpeaker (else all false) | | T20 | bypass excluded from rotation | bypass member never becomes currentSpeaker / never woken by normal rotation | | T21 | mentioned bypass member | current-speaker mention of a bypass user → pushed into a sub-frame (transient), woken; on pop returns to bypass (not added to root order) | | T22 | `moveToBypass` mid-rotation | target removed from order/streak/frames, added to bypass; if target was currentSpeaker → successor takes over (null if order empties) | | T23 | mention nesting cap | max 4 sub-frames (5 levels incl. root); 5th push evicts bottom-most: root→A→B→C→D + E ⇒ root→B→C→D→E | | T24 | member-leave strips bypass | leaver removed from `bypassUserIds` too (no orphan) | ## 6. Fabric.Frontend | # | Test point | Expected | |---|---|---| | F1 | login screen | dark card; default Center base; no API key field | | F2 | channels list | only member/public; per-x_type color; xType badge | | F3 | create-channel modal | name; required Type select; Public checkbox default off; member list **excludes self**; creator auto-added note | | F4 | triage in modal | required On-duty single-select, default = current user | | F5 | custom in modal | optional Listeners multi-select | | F6 | members sidebar | non-public channel selected → split "In channel" / "Guild"; public/no-channel → single list; shows name + (you) | | F7 | join/leave buttons | topbar shows Join (non-member, public) / Leave (member) | | F8 | closed channel | composer replaced by read-only banner; history still rendered | | F9 | markdown render | per-message; HTML-escaped (XSS-safe); unclosed code fence contained to that message; no cross-message bleed | | F10 | `<@id>` mention chip | `@DisplayName` pill (resolved via members; short-id fallback); backtick-wrapped literal; untranslated `<@user.name:>` literal | | F11 | name edit (Settings) | PATCH /auth/me; reflected after save | | F12 | dev mode toggle | shows guild `/ack` + per-message `wakeup` metadata; hidden when off; persists in localStorage | | F13 | guild-token refresh on mount | no stale-token "Failed to load channels" after >15 min / reload | | F14 | message not split | long agent reply renders/stored as one message (no Discord-style chunking) | | F15 | Discord-style dark theme | server rail / channel sidebar / messages / members layout | | F16 | create-modal bypass select | discuss/work only: optional multi-select of guild members; sent as `bypassUserIds`; reset on open/close | | F17 | members panel bypass UI | discuss/work + in-channel list only: bypass members tagged `bypass`; others show "→ bypass" action calling `POST :id/bypass` | ## 7. Fabric.OpenclawPlugin (channel plugin) | # | Test point | Expected | |---|---|---| | P1 | build vs real openclaw SDK | `node install.mjs --build-only` clean | | P2 | install script | `--install` / `--uninstall` / `--openclaw-profile-path`; copies to `~/.openclaw/plugins/fabric` + `openclaw config` | | P3 | plugin loads & registers | `openclaw channels list` → `Fabric … installed, configured, enabled` | | P4 | independence | no openclaw source modified (plugin dir + config only) | | P5 | agent auth | `channels.fabric.accounts..fabricApiKey` → `agent/login` session | | P6 | inbound transport | one socket per agent; joins channel rooms; logs connect/join | | P7 | wakeup → admission | `wakeup:true` → dispatch (agent runs); else drop (kept as history) | | P8 | account → agent routing | requires `cfg.bindings` `{agentId,match:{channel:"fabric",accountId}}`; else falls back to default agent | | P9 | dispatch | `runtime.channel.turn` path: `resolveAgentRoute` + `finalizeInboundContext` + `dispatchInboundReplyWithBase` | | P10 | outbound | agent reply posted back to Fabric **as the agent**, exactly **one** message (no chunking; `disableBlockStreaming`) | | P11 | tools | `fabric-register`; `create-{chat,work,report,discussion}-channel` (→ x_type); `discussion-complete` (summary + close) | | P12 | gateway lifecycle | starts inbound on `gateway_start`, stops on `gateway_stop`; no separate sidecar | | P13 | full round-trip | human posts in Fabric → wakeup → agent runs → reply lands in channel as agent | ## 8. Cross-cutting / infra | # | Test point | Expected | |---|---|---| | X1 | ESM everywhere | all subprojects build & run as ES modules (NodeNext, explicit `.js` imports, CJS deps default-imported) | | X2 | backends boot under ESM | no `ERR_MODULE` / `jwt.sign is not a function` / interop 500s | | X3 | local stack bring-up | 2 mysql + Center + 2 Guilds + Frontend healthy; guild nodes registered; users creatable | | X4 | DB_SYNC schema add | new entities/columns auto-create without data loss (additive) | | X5 | metadata/message separation | one message-id; metadata only at push; frontend/desktop metadata-agnostic; `author=guild` hidden unless debug | | X6 | submodule pointers | parent `Fabric` repo bumped after each submodule change; pushed to `origin/main` | --- ## Known coverage gaps / notes - **Frontend (§6)** is build-verified (tsc) and was manually checked when built; not browser-exercised in automated runs. ESM migration did not touch the frontend. - **Plugin polish (not yet hardened):** per-agent 15-min token refresh for long-lived sockets (currently re-auth only on gateway restart); messages posted before an agent socket finishes joining are missed (no backfill); guild-token reconnect handling is minimal. Phase 2 (B2 firehose) not built. - `discuss`/`work` differ only in x_type label; turn semantics identical — test one, both covered. - Desktop / Android submodules are out of scope (untouched).