Skip to content

War Room Developer Guide

Ed Mozley edited this page Aug 9, 2026 · 1 revision

War Room β€” Developer Guide

How the war room is built, and the handful of decisions that everything else hangs off. For what it does and how to use it, see War Room.


1. πŸ“ The files involved

Colour key: βš™οΈ service Β· πŸ”Œ API Β· πŸ–₯️ page Β· πŸ€– Warbot Β· 🎨 CSS/JS Β· πŸ—„οΈ schema Β· 🩺 health Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
βš™οΈ includes/warroom.php The whole service layer. Channels, access rules, messages, mentions, attachments, search, presence, retention, and the situation-report prompt. Every page and endpoint comes through here, so the access rule exists once
πŸ”Œ api/war-room/poll.php The 3-second heartbeat: new messages, presence in, presence out, the channel list with unread and mention counts β€” one request, four jobs
πŸ”Œ api/war-room/send.php Post a message, JSON or multipart. Both shapes land here because a message with a screenshot is still one message
πŸ”Œ api/war-room/message.php Edit (author only) and delete (author or war_room.manage)
πŸ”Œ api/war-room/channels.php Create / rename / archive a channel, open a DM, list
πŸ”Œ api/war-room/search.php Search the conversations you can see
πŸ”Œ api/war-room/attachment.php Serve one file, authorised against its channel
πŸ”Œ api/war-room/sitrep.php The AI situation report
πŸ”Œ api/war-room/alerts.php What is waiting for me β€” called by the header on every page, so it is the cheapest endpoint in the app
πŸ€– includes/warbot/tools.php The tool registry. Seven read-only tools: name, description, JSON schema, capability, handler
πŸ€– includes/warbot/warbot.php The engine: slash commands (no model), the system prompt, posting as the bot
πŸ”Œ api/war-room/warbot.php Ask Warbot to answer a message. Re-derives all trust; refuses to answer twice
βš™οΈ includes/ai_provider.php aiProviderChatTools() β€” the tool-calling loop, added additively so the eight existing AI features are untouched
πŸ–₯️ war-room/index.php The page. Channel list server-rendered for first paint, then refreshed by the poll
πŸ–₯️ war-room/settings/index.php + manifest.php Retention and the AI provider. The manifest's setting_keys is what authorises those keys
πŸ–₯️ war-room/help.php The user guide, on the shared help.css house style
πŸ–₯️ includes/waffle-menu.php renderWarRoomAlerts() β€” the notifications bell. One shared function, so it reaches all 22 module headers with one edit
🎨 assets/css/war-room.css Everything the module looks like
🎨 assets/js/war-room.js All of the client side
🎨 assets/css/mobile.css LAYER 19
🎨 assets/css/theme.css --war-room-accent and friends, in both palettes
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php, api/system/db_verify.php Seven tables, their indexes, their foreign keys, and the one-time migration off the original shape
🩺 api/system/debug-tools/D008_war_room.php + system/debug-tools/d008/index.php The health check
🌍 lang/en/war-room.php ⚠️ English only. The other 23 locales fall back to English rather than erroring
πŸ“„ CHANGELOG.local.md, README.md, this wiki logged as #1008–#1012

2. πŸ—„οΈ Four kinds of channel, one foreign key

Every conversation is a row in warroom_channels with a kind, so warroom_messages needs exactly one foreign key instead of a nullable column per kind. What differs is where each kind's identity comes from:

Kind Identity Lifecycle
all Fixed. One seeded row None
team team_id, UNIQUE, CASCADE. Name is not stored β€” read from teams at display time None
custom A stored name Create, rename, archive
dm dm_key = "<lower id>:<higher id>", UNIQUE None

πŸ”‘ The team kind keeps every property that made the original "a channel IS a team" rule attractive β€” cannot duplicate, cannot orphan, cannot be renamed into a lie β€” but by constraint rather than by being the whole design.

⚠️ dm_key UNIQUE is not tidiness. Without it, two people opening a DM with each other simultaneously get one conversation each and neither sees the other's β€” precisely the failure you would hit mid-incident.

Channels are created on demand at list time (warRoomEnsureChannels), so a team created ten minutes ago has a channel the first time anybody looks. No installer step, no cron.

The delete rules differ on purpose

Constraint Rule Why
fk_warroom_messages_channel CASCADE delete a channel, its conversation goes
fk_warroom_messages_analyst SET NULL delete an analyst and the conversation SURVIVES β€” this is the record of an incident
fk_warroom_messages_deleter SET NULL a tombstone outlives the person who wrote it
fk_warroom_channels_team CASCADE a team channel goes with its team
fk_warroom_channels_creator SET NULL a channel outlives whoever opened it

D008 checks every one of these, because a wrong rule does nothing at all until somebody is deleted β€” and then it quietly destroys an incident record that nobody looks at again until the review.


3. πŸ” The poll contract

One request every 3 seconds carries messages, presence, the channel list, unread and mention counts. Not SSE: Apache + mod_php holds one process per open connection, so thirty analysts on an EventSource would sit on thirty workers during an incident. It stops while the tab is hidden.

⚠️ m.id > since_id cannot see an EDIT or a DELETE. Both change a message the caller already holds, so its id is below the watermark and it never comes back β€” an edit would stay stale on everyone else's screen. warRoomMessages() therefore also re-sends anything changed in the last 30 seconds.

πŸ› And that is why the client must UPSERT, not append. The first version appended, so a deleted message piled up as one tombstone every three seconds β€” "Message deleted by Sarah Williams", ten times. Fixed by replacing on data-msg-id.

πŸ”‘ The general rule: the moment a poll can repeat itself, its consumer has to be idempotent. Re-delivery and blind append cannot coexist.


4. πŸ”‘ Mentions are resolved from the text, server-side

The client sends no list of ids. That is forced by the composer UX: you pick a name, and may then backspace down to just the first name. Anything the client resolved at pick time is wrong the moment the text is edited, and a hand-typed mention would never resolve at all.

So the body is stored exactly as typed β€” no @[39] tokens, which would leak into search results and into the transcript the AI reads β€” and warRoomResolveMentions() matches longest-first, full names before first names. An ambiguous first name notifies every match: two people looking beats nobody looking.

Recipients are filtered to warRoomChannelAudience(), or naming somebody would put a private channel's name and a snippet into their notifications panel.

⚠️ warRoomChannelAudience() is warRoomCanAccessChannel() turned inside out, and the two must agree. A name that resolves in one but fails the other posts a mention nobody can open.

⚠️ The client's highlighting regex must fall back the same way. It first allowed a surname greedily, so @James hello abc captured "James hello", matched nobody and highlighted nothing β€” while the server, falling back to the first word, was notifying James.

Unread is derived from warroom_reads, not a second column β€” so opening a channel clears its mentions and the two can never disagree.


5. πŸ€– Warbot: the hands and the brain

Needs the internet?
Hands β€” includes/warbot/tools.php No. Plain SQL
Brain β€” the model Yes

The war room exists for the day the internet is down, so Warbot degrades rather than dies: slash commands run the same handlers with no model. A bot that goes silent during the outage it exists for reads as broken, not absent.

The registry is the product. Each tool is declared once, and an MCP server is a second consumer of that same array rather than a rewrite.

  • ⚠️ Read-only, all of them. Anyone in the room can type instructions at Warbot. The limit is in what it can do, not what it is told not to do.
  • ⚠️ is_bot exists because analyst_id NULL already means "deleted analyst". Without the flag every Warbot message renders as Former analyst.
  • The reply is triggered by the browser after the send, so no message waits on a model round trip β€” which makes the trigger untrusted and repeatable, so api/war-room/warbot.php re-derives the channel, the asker and whether Warbot was addressed, and checks reply_to_id before answering.

πŸ› The trap in the tool loop, and it is PHP's not the API's. A tool taking no arguments arrives as "input": {}. json_decode(…, true) makes that an empty PHP array, which re-encodes as [] β€” a JSON array. Anthropic then rejects the echoed assistant turn with tool_use.input: Input should be an object. It fails intermittently, only once the model reaches a parameterless tool, so it looks like a flaky provider. aiProviderChatTools() casts every tool_use input back to an object.

⚠️ The model answers in markdown unless told not to. We render with textContent, so asterisks would show literally. Fixed in the prompt β€” not by rendering HTML from model output, which would undo the rule below.


6. πŸ”’ Security posture

  • Every access check is server-side, on every read and every write β€” never by rendering a shorter channel list. Anything unexpected fails closed.
  • All message text is inserted with textContent. Never innerHTML, not for bodies, not for filenames, not for the AI report. There is no sanitiser on purpose: not producing HTML is stronger than cleaning it.
  • Attachments: stored through includes/uploads.php (the one home for the rules), served through an authorising endpoint with the Content-Type derived from our own extension map. The directory is denied wholesale by .htaccess + web.config.
  • ⚠️ There is no content_type column, deliberately β€” storing the uploader's claim would leave something for a future endpoint to trust by mistake.
  • ⚠️ Retention is not the only way a message disappears. Deleting a team cascades channel β†’ messages β†’ attachment rows, and nothing in that chain touches the filesystem. warRoomSweepOrphanFiles() runs on the send path, hourly, regardless of the retention setting β€” otherwise "keep forever" means the rows go and the bytes stay.

⚠️ When testing access, pair every "cannot do X" with a positive control. The first negative-control pass here showed four clean 403s and proved nothing: the test analyst had no war-room module access, so every refusal was module-level. Grant the module, re-run, then the channel checks mean something.


7. 🩺 D008

System β†’ Debug Tools β†’ D008 exists because this is a break-glass feature: every other module tells you it is broken the moment you use it, but this one is opened for the first time during an incident.

It checks the tables, the delete rules above, the all-hands channel, duplicated DM threads, the attachments folder and its guards, orphaned files, retention backlog, and runs one Warbot tool live β€” a tool whose query names a column that does not exist returns no rows rather than an error, so "registered" proves nothing.

The end-to-end test writes a message, reads it back and records presence inside a transaction that is rolled back, so nothing appears in an open war room. (D007 cannot do this; a full-text search cannot see uncommitted rows.)


8. Gotchas worth knowing before you touch this

  • ⚠️ lastInsertId() returns 0 if anything else touches the connection first. warRoomSend() prunes after inserting; reading the id after the prune gave 0 while the message saved fine. Read the id first.
  • ⚠️ Column names: read them out of information_schema, do not remember them. Four were wrong on the first pass of the Warbot tools β€” status_services/status_incidents (not service_status_*), changes.title, assets.hostname, and CMDB from_object_id/to_object_id (not source_/target_). That last is the dangerous kind: a wrong column returns no rows, not an error, so Warbot reported "no recorded relationships" for every object.
  • ⚠️ Phantom tokens. The module used var(--war-room-accent, #ea580c) everywhere before those tokens existed in theme.css. The fallback masks it β€” and means dark mode never adapts. Grep every var(--x) against theme.css.
  • ⚠️ Bump the cache-busters. mobile.css, theme.css, war-room.css, war-room.js β€” and mobile.css/theme.css are linked from every page, so bump them everywhere.

Reference

  • User guide: War Room Β· Help page: war-room/help.php
  • Related: Mobile‑Friendly Β· Full‑Text Search (and why search here does not use it) Β· Theming and Dark Mode
  • Changelog: #1008 (first cut) Β· #1009 (channels, DMs, search, attachments, situation report) Β· #1010 (mentions, notifications, edit/delete) Β· #1011 (mention typing) Β· #1012 (Warbot)

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally