Skip to main content

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

  1. No recipient targeting — Every message is a broadcast. There is no recipient_id column, so directed messages (e.g., "claude-70783, release lock on file X") require the recipient to poll and filter by inspecting JSON payloads.

  2. No message status tracking — Messages have no status column (pending, delivered, read, expired). The only lifecycle is insertion and TTL-based deletion. There is no way to know if a message was consumed.

  3. Message type buried in JSON — The channel column carries coarse categories (task_broadcast, file_conflict, state, heartbeat) but the actual message type (started, completed, error, request, response) is embedded inside the payload JSON. This prevents efficient SQL filtering.

  4. Excessive duplication — The task_id_validator.py hook calls broadcast_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.

  5. No indexing — The table has no indexes beyond the implicit PRIMARY KEY on id. Queries filtering by sender_id, channel, or created_at perform full table scans.

  6. No priority system — All messages are equal. A critical file conflict warning has the same priority as a routine heartbeat.

  7. Payload opacity — The JSON payload column is opaque to SQL. Common query patterns (find all messages about task X, find all messages from session Y about action Z) require json_extract() or application-level deserialization.

  8. 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.

  9. 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=PILOT with no conflict alert generated.

  10. No session identity in messages — Messages carry sender_id (e.g., "claude-70783") but not session_uuid or project_id. Correlating messages to /continue siblings or project context requires joining to session_registry.

Empirical Evidence (2026-02-10 Dump)

MetricValue
Total messages (current batch)38
Unique message content patterns2
Duplication ratio19:1
Historical message IDs2956-2993 (implies ~2,955 prior messages)
Distinct channels used1 (task_broadcast)
Distinct actions in payload1 (started)
Messages with recipient0 (all broadcasts)
Messages with status tracking0
Sessions on same project (PILOT)2 (no alert generated)
Session lifecycle events0

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_at enables indexed TTL cleanup
  • task_id promoted to column — direct SQL filtering on task-related messages
  • Threading via reply_to for 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_messages to session_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 parsingSELECT * 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-status can report "5 unread messages for claude-19571"
  • 95%+ deduplication — Coalesce window prevents hook-triggered broadcast storms
  • Indexed TTL cleanupDELETE 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_to enable 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 queriesWHERE 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() checks message_dedup before inserting (~0.1ms per call)
  • Wider rows — ~70% more columns per row (offset by dedup reducing total row count)
  • Conflict detection overheadregister_session() does 3-4 extra queries (~2ms)

Risk Mitigation

RiskMitigation
Migration breaks existing callersPython API (publish, poll, broadcast_task) unchanged externally; internal SQL updated
Dedup window too aggressiveDefault 30s coalesce window; configurable per channel via CHANNEL_DEDUP dict
Old inter_session_messages data lostRename to inter_session_messages_v1 as backup; drop after 7 days
Conflict false positivesOnly 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:

  1. register_session() — checks all active sessions for overlap
  2. claim_task() — checks if task is already claimed by another session

Detection Rules

Conflict TypeDetection LogicPriority
project_conflictNew session has same non-NULL project_id as an existing active session3 (critical)
task_conflictclaim_task() called for a task already claimed by another active session3 (critical)
cwd_overlapNew session has same cwd as an existing active session (informational)2 (high)
file_conflictTwo sessions hold advisory locks on overlapping files3 (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_id set)
  • Human operator sees alerts via /session-status and /orient

Message Types

ChannelMessage TypePriorityTTLDedup WindowDescription
task_broadcaststarted0 (routine)600s30sSession began working on task
task_broadcastcompleted1 (normal)600snoneSession finished task
task_broadcasterror2 (high)600snoneSession hit error on task
task_broadcasthandoff1 (normal)1800snoneSession handing task to another
file_conflictlock_request2 (high)300snoneRequesting advisory lock
file_conflictlock_granted1 (normal)300snoneLock granted
file_conflictconflict_warning3 (critical)300snoneTwo sessions editing same file
stateinfo0 (routine)60s15sGeneral session state update
heartbeatping0 (routine)30s10sLiveness signal
directrequest1 (normal)600snoneDirect request to specific session
directresponse1 (normal)600snoneResponse to a direct request
session_lifecyclestarted1 (normal)1800snoneSession registered
session_lifecycleended1 (normal)1800snoneSession unregistered
session_lifecycleresumed1 (normal)1800snone/continue sibling detected
operator_alertproject_conflict3 (critical)3600snoneSame project detected
operator_alerttask_conflict3 (critical)3600snoneSame task claim attempted
operator_alertcwd_overlap2 (high)1800s300sSame working directory
operator_alertfile_conflict3 (critical)3600snoneSame file edit detected

Implementation Details

Phase 1: Schema Migration + ADR Update

Deliverables:

  • Create session_messages table with full schema and indexes
  • Create message_dedup table
  • Rename inter_session_messages to inter_session_messages_v1 (backup)
  • Update _ensure_db() in session_message_bus.py to create new tables

Phase 2: API Update + Lifecycle + Conflict Detection

Deliverables:

  • Update publish() to insert into session_messages with promoted columns + session identity enrichment
  • Update poll() to query session_messages with status filtering
  • Update broadcast_task() to use dedup table before inserting
  • Add send() method for directed messages (recipient_id required)
  • Add mark_delivered() and mark_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.py hook to use dedup-aware broadcast_task()
  • Update /session-status to report unread message counts and operator alerts
  • Update /session-log message 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: check bus.get_operator_alerts() and bus.get_unread() at session start, display alerts prominently before orientation content
  • Update /session-status (v4.1.0) — Step 1c: auto-call bus.mark_delivered() on displayed alerts/messages (acknowledgment workflow: pending → delivered → read)
  • Create /session-message (v1.0.0) — New command for directed cross-session messaging via bus.send(), supports targeted + broadcast modes with delivery tracking
  • Update /cxq (v5.11.0) — Add --bus-feed for chronological timeline merging lifecycle + alerts + task + coordination events
  • Update /session-conflicts (v3.0.0) — --full shows all conflict types, --alerts shows operator alerts
  • Update /session-register (v3.0.0) — Shows conflicts detected on registration, unread messages count
  • Update hooks/file-conflict-bus.py — Changed from file_conflict channel to operator_alert channel with message_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

ComponentChange TypeImpact
scripts/core/session_message_bus.pyModifySchema creation, all CRUD methods, lifecycle events, conflict detection
hooks/task_id_validator.pyModifyUse dedup-aware broadcast
hooks/file-conflict-bus.pyModifyChanged to operator_alert channel with structured fields
commands/orient.mdModifyStep 0c: operator alert surfacing at session start
commands/session-status.mdModifyDisplay unread counts, operator alerts, alert acknowledgment workflow
commands/session-conflicts.mdModify--full and --alerts flags for structured conflict detection
commands/session-register.mdModifyShows conflicts and unread on registration
commands/session-message.mdNewDirected cross-session messaging command
commands/cxq.mdModify--bus-feed chronological timeline, bus query options
messaging.dbMigrateNew 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_messages table created with all columns and indexes
  • message_dedup table created
  • inter_session_messages renamed to inter_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-broadcasts session_lifecycle/started
  • unregister_session() auto-broadcasts session_lifecycle/ended (+ stale cleanup broadcasts)
  • Conflict detection fires on same project_id registration
  • operator_alert messages published for project/task conflicts
  • get_operator_alerts() returns unread critical alerts (with auto_mark_delivered param)
  • /session-status reports 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 _v1 backup table
  • Performance test: 1000 messages insert + query in <1s

Glossary

TermDefinition
TTLTime To Live — seconds before a message expires and is eligible for cleanup
DedupDeduplication — preventing identical repeated messages within a coalesce window
Coalesce WindowTime period during which duplicate messages from the same sender/channel/type are suppressed
Advisory LockA cooperative (non-enforced) lock tracked in the database for visibility, not kernel-level blocking
WALWrite-Ahead Logging — SQLite journal mode enabling concurrent readers during writes
Operator AlertA critical-priority message intended for human operator attention
Session LifecycleThe birth-to-death progression of an LLM session: started -> active -> ended
Conflict DetectionAutomated check for overlapping work (same project, task, CWD, or files) across concurrent sessions

Internal Documentation

Code Locations

  • scripts/core/session_message_bus.py — MessageBus implementation (primary change target)
  • hooks/task_id_validator.py — Hook that triggers broadcast_task() on every tool call
  • hooks/file-conflict-bus.py — Hook for file conflict detection via operator_alert channel
  • commands/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 --full and --alerts flags
  • commands/session-register.md — Registration with conflict display
  • commands/session-message.md — Directed cross-session messaging command
  • commands/cxq.md — Context query with --bus-feed timeline and bus query options
  • commands/session-log.md — Session log with --bus-activity structured message dump (v2.2.0)

Changelog

VersionDateAuthorChanges
1.0.02026-02-10Claude (Opus 4.6)Initial version — structured schema with dedup, directed messaging, delivery tracking
2.0.02026-02-11Claude (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.02026-02-11Claude (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.02026-02-11Claude (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