ADR-173: Structured Inter-Session Message Schema
Document: ADR-173-structured-inter-session-message-schema
Version: 2.2.0
Purpose: Replace the flat inter_session_messages table with a typed, queryable message schema with session lifecycle events, conflict detection, operator alerts, and cross-session coordination commands
Audience: Framework contributors, session coordination developers
Date Created: 2026-02-10
Status: ACCEPTED
Related ADRs:
- ADR-160-inter-session-messaging-architecture (parent — schema evolution)
- ADR-118-four-tier-database-architecture (database tier compliance)
Related Documents:
- scripts/core/session_message_bus.py
Context and Problem Statement
ADR-160 established messaging.db with a minimal inter_session_messages table for inter-session coordination. After 3 weeks of production use (~2,993 messages), the schema has proven insufficient:
Current Schema (ADR-160)
CREATE TABLE inter_session_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id TEXT NOT NULL,
channel TEXT NOT NULL, -- e.g., "task_broadcast"
payload TEXT NOT NULL, -- JSON blob
created_at TEXT NOT NULL DEFAULT (datetime('now')),
ttl_seconds INTEGER DEFAULT 300
);
Observed Problems
-
No recipient targeting — Every message is a broadcast. There is no
recipient_idcolumn, so directed messages (e.g., "claude-70783, release lock on file X") require the recipient to poll and filter by inspecting JSON payloads. -
No message status tracking — Messages have no
statuscolumn (pending, delivered, read, expired). The only lifecycle is insertion and TTL-based deletion. There is no way to know if a message was consumed. -
Message type buried in JSON — The
channelcolumn carries coarse categories (task_broadcast,file_conflict,state,heartbeat) but the actual message type (started, completed, error, request, response) is embedded inside thepayloadJSON. This prevents efficient SQL filtering. -
Excessive duplication — The
task_id_validator.pyhook callsbroadcast_task()on every tool invocation, producing identical{"task_id": "H.0", "action": "started"}messages. On 2026-02-10, claude-70783 sent 22 identical H.0/started broadcasts in 6 minutes. There is no deduplication. -
No indexing — The table has no indexes beyond the implicit PRIMARY KEY on
id. Queries filtering bysender_id,channel, orcreated_atperform full table scans. -
No priority system — All messages are equal. A critical file conflict warning has the same priority as a routine heartbeat.
-
Payload opacity — The JSON
payloadcolumn is opaque to SQL. Common query patterns (find all messages about task X, find all messages from session Y about action Z) requirejson_extract()or application-level deserialization. -
No session lifecycle events — When a new session registers or an existing session ends, no message is published. There is zero awareness of session birth/death across the bus.
-
No conflict detection — Two sessions can work on the same project, claim the same task, or edit the same file without any alert. On 2026-02-10, sessions claude-19571 and claude-13936 both had
project_id=PILOTwith no conflict alert generated. -
No session identity in messages — Messages carry
sender_id(e.g., "claude-70783") but notsession_uuidorproject_id. Correlating messages to/continuesiblings or project context requires joining tosession_registry.
Empirical Evidence (2026-02-10 Dump)
| Metric | Value |
|---|---|
| Total messages (current batch) | 38 |
| Unique message content patterns | 2 |
| Duplication ratio | 19:1 |
| Historical message IDs | 2956-2993 (implies ~2,955 prior messages) |
| Distinct channels used | 1 (task_broadcast) |
| Distinct actions in payload | 1 (started) |
| Messages with recipient | 0 (all broadcasts) |
| Messages with status tracking | 0 |
| Sessions on same project (PILOT) | 2 (no alert generated) |
| Session lifecycle events | 0 |
Decision Drivers
Mandatory Requirements (Must-Have)
- Backward compatible — Existing
publish(),poll(),broadcast_task()APIs must continue to work without breaking callers - Zero new dependencies — SQLite only, consistent with ADR-160
- Queryable by type — Message type, sender, recipient, and status must be filterable via SQL without JSON parsing
- Deduplication — Identical repeated broadcasts must be coalesced, not duplicated
- Session lifecycle events — Session start/end/resume must auto-broadcast
- Conflict detection — Same project, task, CWD, or file must trigger operator alerts
Important Goals (Should-Have)
- Directed messaging — Support sender-to-recipient messages, not just broadcasts
- Delivery tracking — Know whether a message was read/consumed
- Priority levels — Distinguish critical alerts from routine broadcasts
- Efficient cleanup — TTL expiry should be indexed for fast periodic cleanup
- Session identity enrichment — Messages carry session_uuid and project_id for context
Nice-to-Have
- Message threading — Reply-to chain for request/response patterns
- Acknowledgment protocol — Recipient confirms receipt
Considered Options
Option 1: Structured Message Table with Lifecycle and Conflict Detection (SELECTED)
Description: Replace the inter_session_messages table with a new session_messages table that promotes key payload fields to proper columns with indexes, adds session identity enrichment, auto-broadcasts session lifecycle events, and implements conflict detection with operator alerts.
New Schema:
CREATE TABLE session_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id TEXT NOT NULL,
sender_session_uuid TEXT, -- from session_registry for /continue correlation
recipient_id TEXT, -- NULL = broadcast to all
channel TEXT NOT NULL, -- coarse category
message_type TEXT NOT NULL, -- fine-grained type
priority INTEGER NOT NULL DEFAULT 0, -- 0=routine, 1=normal, 2=high, 3=critical
status TEXT NOT NULL DEFAULT 'pending', -- pending, delivered, read, expired
project_id TEXT, -- from session_registry for project-scoped filtering
task_id TEXT, -- promoted from payload for indexed lookup
activity TEXT, -- human-readable context when no project_id
payload TEXT, -- optional JSON for extra data
reply_to INTEGER, -- FK to session_messages.id for threading
created_at TEXT NOT NULL DEFAULT (datetime('now')),
delivered_at TEXT,
read_at TEXT,
expires_at TEXT NOT NULL, -- pre-computed from created_at + ttl
FOREIGN KEY (reply_to) REFERENCES session_messages(id)
);
-- Indexes for common query patterns
CREATE INDEX idx_sm_recipient_status ON session_messages(recipient_id, status)
WHERE status IN ('pending', 'delivered');
CREATE INDEX idx_sm_channel_type ON session_messages(channel, message_type);
CREATE INDEX idx_sm_task_id ON session_messages(task_id) WHERE task_id IS NOT NULL;
CREATE INDEX idx_sm_expires_at ON session_messages(expires_at);
CREATE INDEX idx_sm_sender_created ON session_messages(sender_id, created_at);
CREATE INDEX idx_sm_project_id ON session_messages(project_id) WHERE project_id IS NOT NULL;
Deduplication Table:
CREATE TABLE message_dedup (
sender_id TEXT NOT NULL,
channel TEXT NOT NULL,
message_type TEXT NOT NULL,
task_id TEXT,
last_message_id INTEGER NOT NULL,
last_sent_at TEXT NOT NULL,
coalesce_window_seconds INTEGER NOT NULL DEFAULT 30,
PRIMARY KEY (sender_id, channel, message_type, task_id)
);
-- Note: task_id is TEXT NOT NULL DEFAULT '' (empty string for NULL-safe PK)
-- SQLite does not support expressions in PRIMARY KEY constraints
Pros:
- All common query patterns use indexed columns — no JSON parsing required
- Directed messaging via
recipient_id(NULL = broadcast) - Full lifecycle tracking: pending -> delivered -> read -> expired
- Deduplication prevents 22 identical broadcasts in 6 minutes
- Pre-computed
expires_atenables indexed TTL cleanup task_idpromoted to column — direct SQL filtering on task-related messages- Threading via
reply_tofor request/response patterns - Priority levels enable critical alerts to surface above routine noise
- Session identity (
sender_session_uuid,project_id) enables project-scoped queries - Session lifecycle events provide birth/death awareness
- Conflict detection prevents silent session stomping
Cons:
- Migration required from
inter_session_messagestosession_messages - Slightly more complex insert path (dedup check + enrichment before insert)
- More columns = slightly larger row size
- Conflict detection adds ~2ms to
register_session()calls
Effort: Medium-High
Option 2: Add Columns to Existing Table (REJECTED)
Same as v1.0.0. Rejected for same reasons: SQLite ALTER TABLE limitations, no dedup, no conflict detection.
Option 3: Separate Tables per Message Type (REJECTED)
Same as v1.0.0. Rejected for same reasons: table proliferation, UNION ALL queries.
Decision Outcome
Chosen Option: Option 1 — Structured Message Table with Lifecycle and Conflict Detection
Rationale: The current flat schema stores everything in a JSON blob, making the database unable to do what databases do best — filter, index, and query structured data. Option 1 promotes the fields that are actually queried (recipient, type, status, task_id, priority, project_id) into proper columns while keeping payload as an optional JSON overflow field. The dedup table solves the 19:1 duplication ratio. Session lifecycle events and conflict detection solve the silent-stomping problem observed in production.
Consequences
Positive Consequences
- Queryable without JSON parsing —
SELECT * FROM session_messages WHERE task_id = 'H.0' AND message_type = 'completed'works directly - Directed messaging — Sessions can send to specific recipients:
WHERE recipient_id = 'claude-70783' AND status = 'pending' - Delivery tracking —
/session-statuscan report "5 unread messages for claude-19571" - 95%+ deduplication — Coalesce window prevents hook-triggered broadcast storms
- Indexed TTL cleanup —
DELETE FROM session_messages WHERE expires_at < datetime('now')uses index - Priority filtering — Critical file conflict alerts surface above routine heartbeats
- Threading — Request/response chains via
reply_toenable coordinated workflows - Session lifecycle awareness — All sessions know when peers start, end, or resume
- Conflict detection — Same-project/task/CWD overlaps immediately alert operator and affected sessions
- Project-scoped queries —
WHERE project_id = 'PILOT'without JSON parsing or registry joins
Negative Consequences
- Migration effort — Must create new table, migrate data, update Python API
- Dedup overhead — Each
publish()checksmessage_dedupbefore inserting (~0.1ms per call) - Wider rows — ~70% more columns per row (offset by dedup reducing total row count)
- Conflict detection overhead —
register_session()does 3-4 extra queries (~2ms)
Risk Mitigation
| Risk | Mitigation |
|---|---|
| Migration breaks existing callers | Python API (publish, poll, broadcast_task) unchanged externally; internal SQL updated |
| Dedup window too aggressive | Default 30s coalesce window; configurable per channel via CHANNEL_DEDUP dict |
Old inter_session_messages data lost | Rename to inter_session_messages_v1 as backup; drop after 7 days |
| Conflict false positives | Only alert on same project_id (non-NULL), same task_id claim, or same active file — not mere CWD overlap |
Session Lifecycle Events
Auto-Broadcast on Registration
When register_session() is called, the bus automatically publishes:
# Channel: session_lifecycle, Type: started
bus.publish("session_lifecycle", {
"session_id": "claude-70783",
"session_uuid": "756269ce-...",
"project_id": "PILOT",
"llm_vendor": "claude",
"llm_model": "opus-4.6",
"cwd": "/Users/.../coditect-rollout-master",
})
Auto-Broadcast on Unregistration
When unregister_session() is called:
# Channel: session_lifecycle, Type: ended
bus.publish("session_lifecycle", {
"session_id": "claude-70783",
"reason": "session_complete",
})
Resume Detection
When a session registers with a session_uuid that matches an existing active session (i.e., /continue siblings):
# Channel: session_lifecycle, Type: resumed
bus.publish("session_lifecycle", {
"session_id": "claude-45872",
"session_uuid": "756269ce-...",
"sibling_id": "claude-70783", # existing session with same UUID
})
Conflict Detection Engine
Triggers
Conflict detection runs automatically in two places:
register_session()— checks all active sessions for overlapclaim_task()— checks if task is already claimed by another session
Detection Rules
| Conflict Type | Detection Logic | Priority |
|---|---|---|
project_conflict | New session has same non-NULL project_id as an existing active session | 3 (critical) |
task_conflict | claim_task() called for a task already claimed by another active session | 3 (critical) |
cwd_overlap | New session has same cwd as an existing active session (informational) | 2 (high) |
file_conflict | Two sessions hold advisory locks on overlapping files | 3 (critical) |
Alert Message Format
# operator_alert / project_conflict
{
"conflicting_sessions": ["claude-19571", "claude-13936"],
"conflict_type": "project_conflict",
"project_id": "PILOT",
"message": "2 sessions working on project PILOT simultaneously",
"recommendation": "Verify intentional parallel work or reassign tasks",
}
Alert Recipients
- Broadcast to all active sessions (channel
operator_alert) - Directed to conflicting sessions specifically (
recipient_idset) - Human operator sees alerts via
/session-statusand/orient
Message Types
| Channel | Message Type | Priority | TTL | Dedup Window | Description |
|---|---|---|---|---|---|
task_broadcast | started | 0 (routine) | 600s | 30s | Session began working on task |
task_broadcast | completed | 1 (normal) | 600s | none | Session finished task |
task_broadcast | error | 2 (high) | 600s | none | Session hit error on task |
task_broadcast | handoff | 1 (normal) | 1800s | none | Session handing task to another |
file_conflict | lock_request | 2 (high) | 300s | none | Requesting advisory lock |
file_conflict | lock_granted | 1 (normal) | 300s | none | Lock granted |
file_conflict | conflict_warning | 3 (critical) | 300s | none | Two sessions editing same file |
state | info | 0 (routine) | 60s | 15s | General session state update |
heartbeat | ping | 0 (routine) | 30s | 10s | Liveness signal |
direct | request | 1 (normal) | 600s | none | Direct request to specific session |
direct | response | 1 (normal) | 600s | none | Response to a direct request |
session_lifecycle | started | 1 (normal) | 1800s | none | Session registered |
session_lifecycle | ended | 1 (normal) | 1800s | none | Session unregistered |
session_lifecycle | resumed | 1 (normal) | 1800s | none | /continue sibling detected |
operator_alert | project_conflict | 3 (critical) | 3600s | none | Same project detected |
operator_alert | task_conflict | 3 (critical) | 3600s | none | Same task claim attempted |
operator_alert | cwd_overlap | 2 (high) | 1800s | 300s | Same working directory |
operator_alert | file_conflict | 3 (critical) | 3600s | none | Same file edit detected |
Implementation Details
Phase 1: Schema Migration + ADR Update
Deliverables:
- Create
session_messagestable with full schema and indexes - Create
message_deduptable - Rename
inter_session_messagestointer_session_messages_v1(backup) - Update
_ensure_db()insession_message_bus.pyto create new tables
Phase 2: API Update + Lifecycle + Conflict Detection
Deliverables:
- Update
publish()to insert intosession_messageswith promoted columns + session identity enrichment - Update
poll()to querysession_messageswith status filtering - Update
broadcast_task()to use dedup table before inserting - Add
send()method for directed messages (recipient_id required) - Add
mark_delivered()andmark_read()methods - Add
get_unread(session_id)method - Add
_detect_conflicts()private method - Update
register_session()to auto-broadcast lifecycle event + run conflict detection - Update
unregister_session()to auto-broadcast lifecycle event - Add
get_operator_alerts()method
Phase 3: Hook + Command Update + Verification
Deliverables:
- Update
task_id_validator.pyhook to use dedup-awarebroadcast_task() - Update
/session-statusto report unread message counts and operator alerts - Update
/session-logmessage dump to use structured columns - Verify migration works with existing callers
Phase 4: Command Integration and Cross-Session Coordination (v2.1.0)
Deliverables:
- Update
/orient(v3.0.0) — Step 0c: checkbus.get_operator_alerts()andbus.get_unread()at session start, display alerts prominently before orientation content - Update
/session-status(v4.1.0) — Step 1c: auto-callbus.mark_delivered()on displayed alerts/messages (acknowledgment workflow: pending → delivered → read) - Create
/session-message(v1.0.0) — New command for directed cross-session messaging viabus.send(), supports targeted + broadcast modes with delivery tracking - Update
/cxq(v5.11.0) — Add--bus-feedfor chronological timeline merging lifecycle + alerts + task + coordination events - Update
/session-conflicts(v3.0.0) —--fullshows all conflict types,--alertsshows operator alerts - Update
/session-register(v3.0.0) — Shows conflicts detected on registration, unread messages count - Update
hooks/file-conflict-bus.py— Changed fromfile_conflictchannel tooperator_alertchannel withmessage_type="file_conflict", priority=3
Delivery Tracking Loop:
pending → delivered → read
↑ ↑ ↑
│ │ │
publish() /session-status user action
or /orient shows (release task,
send() alert to user change CWD, etc.)
Affected Components
| Component | Change Type | Impact |
|---|---|---|
scripts/core/session_message_bus.py | Modify | Schema creation, all CRUD methods, lifecycle events, conflict detection |
hooks/task_id_validator.py | Modify | Use dedup-aware broadcast |
hooks/file-conflict-bus.py | Modify | Changed to operator_alert channel with structured fields |
commands/orient.md | Modify | Step 0c: operator alert surfacing at session start |
commands/session-status.md | Modify | Display unread counts, operator alerts, alert acknowledgment workflow |
commands/session-conflicts.md | Modify | --full and --alerts flags for structured conflict detection |
commands/session-register.md | Modify | Shows conflicts and unread on registration |
commands/session-message.md | New | Directed cross-session messaging command |
commands/cxq.md | Modify | --bus-feed chronological timeline, bus query options |
messaging.db | Migrate | New tables, backup old table |
API Changes
New Methods
# Directed message to specific session
bus.send(
recipient_id="claude-19571",
message_type="request",
payload={"action": "release_lock", "file": "paths.py"},
priority=2
)
# Get unread messages for this session
unread = bus.get_unread() # returns List[SessionMessage]
# Mark message as read
bus.mark_read(message_id=3001)
# Mark message as delivered (consumed by poll)
bus.mark_delivered(message_id=3001)
# Get operator alerts (critical priority, unread)
alerts = bus.get_operator_alerts() # returns List[SessionMessage]
Updated Methods
# publish() now uses dedup, promoted columns, and session identity enrichment
bus.publish("task_broadcast", {"task_id": "H.0", "action": "started"})
# Internally: checks dedup table, enriches with session_uuid + project_id from registry,
# inserts into session_messages with message_type="started", task_id="H.0", priority=0
# poll() now supports status filtering
messages = bus.poll("task_broadcast", since_id=42, status="pending")
# broadcast_task() unchanged externally, dedup-aware internally
bus.broadcast_task("H.0", "started")
# If last H.0/started from this sender was <30s ago: skip (return last_message_id)
# register_session() now auto-broadcasts lifecycle event + detects conflicts
bus.register_session(session_id="claude-45872", llm_vendor="claude", ...)
# Internally: INSERT into registry, broadcast session_lifecycle/started,
# run _detect_conflicts(), publish operator_alert if conflicts found
# unregister_session() now auto-broadcasts lifecycle event
bus.unregister_session("claude-45872")
# Internally: broadcast session_lifecycle/ended, then DELETE from registry
Validation and Compliance
Success Criteria
-
session_messagestable created with all columns and indexes -
message_deduptable created -
inter_session_messagesrenamed tointer_session_messages_v1 -
publish()inserts into new table with promoted columns + session enrichment -
poll()queries new table with optional status filter -
broadcast_task()checks dedup before inserting - Dedup reduces identical broadcasts by >90% (verified: 5 rapid publishes → 1 message)
-
send()method works for directed messages -
mark_read()/mark_delivered()update status (verified: pending → delivered → read lifecycle) -
register_session()auto-broadcastssession_lifecycle/started -
unregister_session()auto-broadcastssession_lifecycle/ended(+ stale cleanup broadcasts) - Conflict detection fires on same project_id registration
-
operator_alertmessages published for project/task conflicts -
get_operator_alerts()returns unread critical alerts (withauto_mark_deliveredparam) -
/session-statusreports unread counts and operator alerts (v4.1.0) - All existing callers work without modification (task-id-validator, file-conflict-bus, session-register-bus)
Testing Requirements
- Unit tests:
publish(),poll(),send(), dedup coalesce logic - Integration test: Two simulated sessions exchanging directed messages
- Conflict detection test: Register two sessions with same project_id, verify alert
- Lifecycle test: Register + unregister, verify lifecycle messages published
- Migration test: Verify old data preserved in
_v1backup table - Performance test: 1000 messages insert + query in <1s
Glossary
| Term | Definition |
|---|---|
| TTL | Time To Live — seconds before a message expires and is eligible for cleanup |
| Dedup | Deduplication — preventing identical repeated messages within a coalesce window |
| Coalesce Window | Time period during which duplicate messages from the same sender/channel/type are suppressed |
| Advisory Lock | A cooperative (non-enforced) lock tracked in the database for visibility, not kernel-level blocking |
| WAL | Write-Ahead Logging — SQLite journal mode enabling concurrent readers during writes |
| Operator Alert | A critical-priority message intended for human operator attention |
| Session Lifecycle | The birth-to-death progression of an LLM session: started -> active -> ended |
| Conflict Detection | Automated check for overlapping work (same project, task, CWD, or files) across concurrent sessions |
Links
Internal Documentation
- ADR-160: Inter-Session Messaging Architecture — parent decision
- ADR-118: Four-Tier Database Architecture — database tier compliance
Code Locations
scripts/core/session_message_bus.py— MessageBus implementation (primary change target)hooks/task_id_validator.py— Hook that triggersbroadcast_task()on every tool callhooks/file-conflict-bus.py— Hook for file conflict detection viaoperator_alertchannelcommands/orient.md— Session orientation with operator alert surfacing (Step 0c)commands/session-status.md— Dashboard command with alert acknowledgment workflow (Step 1c)commands/session-conflicts.md— Conflict detection with--fulland--alertsflagscommands/session-register.md— Registration with conflict displaycommands/session-message.md— Directed cross-session messaging commandcommands/cxq.md— Context query with--bus-feedtimeline and bus query optionscommands/session-log.md— Session log with--bus-activitystructured message dump (v2.2.0)
Changelog
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0.0 | 2026-02-10 | Claude (Opus 4.6) | Initial version — structured schema with dedup, directed messaging, delivery tracking |
| 2.0.0 | 2026-02-11 | Claude (Opus 4.6) | Expanded scope: session lifecycle events, conflict detection engine, operator alerts, sender_session_uuid/project_id/activity columns, 7 new message types |
| 2.1.0 | 2026-02-11 | Claude (Opus 4.6) | Phase 4: /orient alert surfacing, /session-status acknowledgment workflow (mark_delivered), /session-message command, /cxq --bus-feed timeline, dedup PK fix (COALESCE→NOT NULL DEFAULT ''), 10 affected components documented |
| 2.2.0 | 2026-02-11 | Claude (Opus 4.6) | Phase 2+3 implementation complete: CHANNEL_DEDUP expanded (session_lifecycle, operator_alert), stale sessions broadcast ended events, auto_mark_delivered on get_unread/get_operator_alerts, task-id-validator hook uses dedup-aware broadcast, /session-log --bus-activity structured dump, all 16 success criteria verified |
Compliance: CODITECT-STANDARD-ADR v1.0.0