Move old docs into _old_structure dir. Create blank GETTING_STARTED.md document
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# ADR: Optional AI egress gateways for runtime-neutral Mos
|
||||
|
||||
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
|
||||
|
||||
**Date:** 2026-07-14
|
||||
|
||||
**Issues:** #754, #755
|
||||
|
||||
**Decision owner:** Mosaic Gateway / provider-adapter architecture
|
||||
|
||||
## Context
|
||||
|
||||
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
|
||||
|
||||
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
|
||||
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
|
||||
|
||||
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
|
||||
|
||||
## Decision
|
||||
|
||||
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
|
||||
|
||||
Mosaic Gateway remains authoritative for:
|
||||
|
||||
- authenticated actor and tenant identity;
|
||||
- logical agent identity and connector binding;
|
||||
- authorization, approval, and policy;
|
||||
- lease epoch and stale-holder fencing;
|
||||
- audit correlation and redaction;
|
||||
- canonical handoff/checkpoint state;
|
||||
- idempotency and side-effect receipts.
|
||||
|
||||
An egress gateway MUST NOT:
|
||||
|
||||
- receive channel ingress directly;
|
||||
- authorize tools or connector ownership;
|
||||
- define Mosaic tenant or agent identity;
|
||||
- persist raw Mosaic handoffs or channel credentials;
|
||||
- bypass adapter capability negotiation;
|
||||
- silently fail over when policy, lease, or provider health is uncertain.
|
||||
|
||||
Allowed topology:
|
||||
|
||||
```text
|
||||
Discord / Matrix / CLI / web
|
||||
↓
|
||||
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
|
||||
↓
|
||||
IProviderAdapter / AgentRuntimeProvider
|
||||
↓
|
||||
optional egress gateway
|
||||
↓
|
||||
upstream provider or subscription-backed OAuth session
|
||||
```
|
||||
|
||||
## Candidate assessment
|
||||
|
||||
### Purpose-built `raine/claude-code-proxy`
|
||||
|
||||
**Disposition:** Approved only for the verified emergency localhost bridge.
|
||||
|
||||
Strengths:
|
||||
|
||||
- explicit Codex device OAuth flow;
|
||||
- small operational surface;
|
||||
- Anthropic Messages translation suitable for Claude Code;
|
||||
- model and reasoning-effort enforcement;
|
||||
- straightforward loopback systemd supervision and rollback.
|
||||
|
||||
Constraints:
|
||||
|
||||
- not a Mosaic multi-tenant control plane;
|
||||
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
|
||||
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
|
||||
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
|
||||
|
||||
### LiteLLM
|
||||
|
||||
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
|
||||
|
||||
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
|
||||
|
||||
Required prototype gates:
|
||||
|
||||
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
|
||||
- document token location, encryption, revocation, refresh, scope, and incident response;
|
||||
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
|
||||
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
|
||||
- fail closed instead of selecting an unhealthy provider merely to return a result;
|
||||
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
|
||||
- keep channel ingress and connector credentials outside LiteLLM.
|
||||
|
||||
Source references:
|
||||
|
||||
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
|
||||
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
|
||||
|
||||
### Bifrost
|
||||
|
||||
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
|
||||
|
||||
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
|
||||
|
||||
Required prototype gates:
|
||||
|
||||
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
|
||||
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
|
||||
- prove failover does not violate connector lease, approval, or exactly-once semantics;
|
||||
- ensure request/response logs are redacted before persistence;
|
||||
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
|
||||
|
||||
Source references:
|
||||
|
||||
- [Bifrost overview](https://docs.getbifrost.ai/overview)
|
||||
- [Bifrost repository](https://github.com/maximhq/bifrost)
|
||||
|
||||
### `teremterem/claude-code-gpt-5-codex`
|
||||
|
||||
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
|
||||
|
||||
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
|
||||
|
||||
Source references:
|
||||
|
||||
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
|
||||
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
|
||||
|
||||
## Security consequences
|
||||
|
||||
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
|
||||
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
|
||||
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
|
||||
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
|
||||
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
|
||||
|
||||
## Acceptance before production use
|
||||
|
||||
1. Threat model and provider-terms review approved.
|
||||
2. Credential lifecycle and revocation drill documented and exercised.
|
||||
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
|
||||
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
|
||||
5. Failure injection proves no duplicate side effects across retries or provider failover.
|
||||
6. Rollback to the prior provider path is exercised.
|
||||
7. Independent code and security reviews approve the exact deployed revision.
|
||||
|
||||
## Follow-up
|
||||
|
||||
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
|
||||
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
|
||||
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.
|
||||
@@ -0,0 +1,751 @@
|
||||
# Channel Protocol Architecture
|
||||
|
||||
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
|
||||
**Authors:** Mosaic Core Team
|
||||
**Last Updated:** 2026-07-14
|
||||
**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
|
||||
|
||||
The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
|
||||
|
||||
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
|
||||
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
|
||||
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
|
||||
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
|
||||
|
||||
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
|
||||
|
||||
---
|
||||
|
||||
## M7-001: OfficialChannelAdapter Interface
|
||||
|
||||
```typescript
|
||||
interface OfficialChannelAdapter {
|
||||
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
|
||||
readonly name: string;
|
||||
/** Establish both native-channel and gateway connections. */
|
||||
start(): Promise<void>;
|
||||
/** Gracefully close connections and release resources. */
|
||||
stop(): Promise<void>;
|
||||
/** Best-effort health; ordinary disconnection is a result, not an exception. */
|
||||
health(): Promise<{
|
||||
status: 'connected' | 'degraded' | 'disconnected';
|
||||
detail?: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
|
||||
|
||||
### Stable conversation route
|
||||
|
||||
```typescript
|
||||
interface ChannelConversationRouteDto {
|
||||
bindingId: string;
|
||||
logicalAgentId: string;
|
||||
conversationId: string;
|
||||
channelName: string;
|
||||
authorizationChannelId: string;
|
||||
responseTarget: { channelId: string; threadId?: string };
|
||||
}
|
||||
```
|
||||
|
||||
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
|
||||
|
||||
### Typed ingress and egress ports
|
||||
|
||||
`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
|
||||
|
||||
`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
|
||||
|
||||
Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
|
||||
|
||||
### Adapter Registration
|
||||
|
||||
Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract.
|
||||
|
||||
```
|
||||
ChannelRegistry
|
||||
└── register(adapter: OfficialChannelAdapter): void
|
||||
└── getAdapter(name: string): OfficialChannelAdapter | null
|
||||
└── listAdapters(): OfficialChannelAdapter[]
|
||||
└── healthAll(): Promise<Record<string, AdapterHealth>>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## M7-002: ChannelMessageDto Protocol
|
||||
|
||||
### Canonical Message Format
|
||||
|
||||
```typescript
|
||||
interface ChannelMessageDto {
|
||||
/**
|
||||
* Globally unique message ID.
|
||||
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
|
||||
* when sending. Channel-native IDs are stored in metadata.channelMessageId.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Channel-native room/conversation/channel identifier.
|
||||
* The adapter populates this from the inbound message.
|
||||
* For outbound messages, the caller supplies the target channel.
|
||||
*/
|
||||
channelName: string;
|
||||
channelId: string;
|
||||
|
||||
/**
|
||||
* Channel-native identifier of the message sender.
|
||||
* For Mosaic-originated messages this is the Mosaic userId or agentId.
|
||||
*/
|
||||
senderId: string;
|
||||
|
||||
/** Sender classification. */
|
||||
senderKind: 'user' | 'agent' | 'system';
|
||||
|
||||
/**
|
||||
* Textual content of the message.
|
||||
* For non-text content types (image, file) this may be an empty string
|
||||
* or an alt-text description; the actual payload is in `attachments`.
|
||||
*/
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Hint for how `content` should be interpreted and rendered.
|
||||
* - "text" — plain text, no special rendering
|
||||
* - "markdown" — CommonMark markdown
|
||||
* - "code" — code block (use metadata.language for the language tag)
|
||||
* - "image" — binary image; content is empty, see attachments
|
||||
* - "file" — binary file; content is empty, see attachments
|
||||
*/
|
||||
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
|
||||
|
||||
/**
|
||||
* Arbitrary key-value metadata for channel-specific extension fields.
|
||||
* Examples: { channelMessageId, language, reactionEmoji, channelType }.
|
||||
* Adapters should store channel-native IDs here so round-trip correlation
|
||||
* is possible without altering the canonical fields.
|
||||
*/
|
||||
metadata: Readonly<Record<string, ChannelMetadataValue>>;
|
||||
|
||||
/**
|
||||
* Optional thread or reply-chain identifier.
|
||||
* For threaded channels (Matrix, Discord threads, Telegram topics) this
|
||||
* groups messages into a logical thread scoped to the same channelId.
|
||||
*/
|
||||
threadId?: string;
|
||||
|
||||
/**
|
||||
* The canonical message ID this message is a reply to.
|
||||
* Maps to channel-native reply/quote mechanisms in each adapter.
|
||||
*/
|
||||
replyToId?: string;
|
||||
|
||||
/**
|
||||
* Binary or URI-referenced attachments.
|
||||
* Each attachment carries its MIME type and a URL or base64 payload.
|
||||
*/
|
||||
attachments?: readonly ChannelAttachmentDto[];
|
||||
|
||||
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChannelAttachmentDto {
|
||||
/** Channel-native attachment identifier. */
|
||||
id: string;
|
||||
|
||||
/** Filename or display name. */
|
||||
name: string;
|
||||
|
||||
/** MIME type when supplied by the channel. */
|
||||
mimeType: string | null;
|
||||
|
||||
/**
|
||||
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
|
||||
* Adapters that receive file uploads SHOULD store to object storage and
|
||||
* populate a stable URL here rather than embedding the raw bytes.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/** Size in bytes, if known. */
|
||||
sizeBytes?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel Translation Reference
|
||||
|
||||
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
|
||||
|
||||
### Matrix
|
||||
|
||||
| ChannelMessageDto field | Matrix equivalent |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
|
||||
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
|
||||
| `senderId` | Matrix user ID (`@user:homeserver`) |
|
||||
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
|
||||
| `content` | `event.content.body` |
|
||||
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
|
||||
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
|
||||
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
|
||||
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
|
||||
| `timestamp` | `new Date(event.origin_server_ts)` |
|
||||
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` |
|
||||
|
||||
**Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body.
|
||||
|
||||
---
|
||||
|
||||
### Discord
|
||||
|
||||
| ChannelMessageDto field | Discord equivalent |
|
||||
| ----------------------- | ----------------------------------------------------------------------- |
|
||||
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
|
||||
| `channelId` | Discord channel ID (snowflake string) |
|
||||
| `senderId` | Discord user ID (snowflake) |
|
||||
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
|
||||
| `content` | `message.content` |
|
||||
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
|
||||
| `threadId` | `message.thread.id` when the message is inside a thread channel |
|
||||
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
|
||||
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
|
||||
| `timestamp` | `new Date(message.timestamp)` |
|
||||
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
|
||||
|
||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
||||
|
||||
### Discord routing and thread policy
|
||||
|
||||
A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
|
||||
|
||||
| Inbound location/trigger | Conversation and response target |
|
||||
| ------------------------------------------ | --------------------------------------------------------------- |
|
||||
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
|
||||
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
|
||||
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
|
||||
| `/approve` or `/stop <approval>` | Current parent/thread durable session; no new topic is created |
|
||||
|
||||
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
|
||||
|
||||
### Discord service ingress security
|
||||
|
||||
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
|
||||
|
||||
---
|
||||
|
||||
### Telegram
|
||||
|
||||
| ChannelMessageDto field | Telegram equivalent |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
|
||||
| `channelId` | Telegram `chat_id` (integer as string) |
|
||||
| `senderId` | Telegram `from.id` (integer as string) |
|
||||
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
|
||||
| `content` | `message.text` or `message.caption` |
|
||||
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
|
||||
| `threadId` | `message.message_thread_id` (for supergroup topics) |
|
||||
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
|
||||
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
|
||||
| `timestamp` | `new Date(message.date * 1000)` |
|
||||
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
|
||||
|
||||
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
|
||||
|
||||
---
|
||||
|
||||
### TUI (Terminal UI)
|
||||
|
||||
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
|
||||
|
||||
| ChannelMessageDto field | TUI equivalent |
|
||||
| ----------------------- | ------------------------------------------------------------------ |
|
||||
| `id` | Generated UUID (TUI has no native message IDs) |
|
||||
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
|
||||
| `senderId` | Authenticated Mosaic `userId` |
|
||||
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
|
||||
| `content` | Raw text from stdin / agent output |
|
||||
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
|
||||
| `threadId` | Not used (TUI sessions are linear) |
|
||||
| `replyToId` | Not used |
|
||||
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
|
||||
| `timestamp` | `new Date()` at the moment of send |
|
||||
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` |
|
||||
|
||||
**Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`.
|
||||
|
||||
---
|
||||
|
||||
### WebUI
|
||||
|
||||
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
|
||||
|
||||
| ChannelMessageDto field | WebUI equivalent |
|
||||
| ----------------------- | ------------------------------------------------------------ |
|
||||
| `id` | Generated UUID; echoed back in the WebSocket event |
|
||||
| `channelId` | `"webui:<conversationId>"` |
|
||||
| `senderId` | Authenticated Mosaic `userId` |
|
||||
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
|
||||
| `content` | Message text from the input field |
|
||||
| `contentKind` | `"text"` or `"markdown"` |
|
||||
| `threadId` | Not used (conversation model handles threading) |
|
||||
| `replyToId` | Message ID the user replied to (UI reply affordance) |
|
||||
| `attachments` | Files uploaded via the file picker; stored to object storage |
|
||||
| `timestamp` | `new Date()` at send, or server timestamp from event |
|
||||
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` |
|
||||
|
||||
**Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component.
|
||||
|
||||
---
|
||||
|
||||
## Identity Mapping
|
||||
|
||||
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
|
||||
|
||||
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
|
||||
|
||||
```
|
||||
channel_identities
|
||||
channel_name TEXT -- e.g. "matrix", "discord"
|
||||
channel_user_id TEXT -- channel-native user identifier
|
||||
mosaic_user_id TEXT -- FK to users.id
|
||||
linked_at TIMESTAMP
|
||||
PRIMARY KEY (channel_name, channel_user_id)
|
||||
```
|
||||
|
||||
Identity linking flows (OAuth dance, deep-link verification token, etc.) are out of scope for this document and will be specified in a separate identity-linking protocol document.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Conventions
|
||||
|
||||
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
|
||||
- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.
|
||||
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
|
||||
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
|
||||
|
||||
Current version: **1.0.0**
|
||||
|
||||
---
|
||||
|
||||
## M7-003: Matrix Integration Design
|
||||
|
||||
### Homeserver Choice
|
||||
|
||||
Mosaic uses **Conduit** as the Matrix homeserver. Conduit is written in Rust, ships as a single binary, and has minimal operational overhead compared to Synapse or Dendrite. It supports the full Matrix Client-Server and Application Service APIs required by Mosaic.
|
||||
|
||||
Recommended deployment: Conduit runs as a Docker container alongside the Mosaic stack. A single Conduit instance is sufficient for most self-hosted deployments. Conduit's embedded RocksDB storage means no separate database is required for the homeserver itself.
|
||||
|
||||
### Appservice Registration
|
||||
|
||||
Mosaic registers with the Conduit homeserver as a Matrix **Application Service (appservice)**. This gives Mosaic the ability to:
|
||||
|
||||
- Create and control ghost users (virtual Matrix users representing Mosaic agents and provisioned accounts).
|
||||
- Receive all events sent to rooms within the appservice's namespace without polling.
|
||||
- Send events on behalf of ghost users without separate authentication.
|
||||
|
||||
Registration is done via a YAML registration file (`mosaic-appservice.yaml`) placed in Conduit's configuration directory:
|
||||
|
||||
```yaml
|
||||
id: mosaic
|
||||
url: http://gateway:3000/_matrix/appservice
|
||||
as_token: <random-secret>
|
||||
hs_token: <random-secret>
|
||||
sender_localpart: mosaic-bot
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: true
|
||||
regex: '@mosaic_.*:homeserver'
|
||||
rooms:
|
||||
- exclusive: false
|
||||
regex: '.*'
|
||||
aliases:
|
||||
- exclusive: true
|
||||
regex: '#mosaic-.*:homeserver'
|
||||
```
|
||||
|
||||
The gateway exposes `/_matrix/appservice` endpoints to receive push events from Conduit. The `as_token` and `hs_token` are stored in Vault and injected at startup.
|
||||
|
||||
### Room ↔ Conversation Mapping
|
||||
|
||||
Each Mosaic conversation maps to a single Matrix room. The mapping is stored in the database:
|
||||
|
||||
```
|
||||
conversation_matrix_rooms
|
||||
conversation_id TEXT -- FK to conversations.id
|
||||
room_id TEXT -- Matrix room ID (!roomid:homeserver)
|
||||
created_at TIMESTAMP
|
||||
PRIMARY KEY (conversation_id)
|
||||
```
|
||||
|
||||
Room creation is handled by the appservice on the first Matrix access to a conversation. Room names follow the pattern `Mosaic: <conversation title>`. Room topics contain the conversation ID for correlation.
|
||||
|
||||
When a conversation is deleted or archived in Mosaic, the corresponding Matrix room is tombstoned (m.room.tombstone event) and the room is left in a read-only state.
|
||||
|
||||
### Space ↔ Team Mapping
|
||||
|
||||
Each Mosaic team maps to a Matrix **Space**. Spaces are Matrix rooms with a special `m.space` type that can contain child rooms.
|
||||
|
||||
```
|
||||
team_matrix_spaces
|
||||
team_id TEXT -- FK to teams.id
|
||||
space_id TEXT -- Matrix room ID of the Space
|
||||
created_at TIMESTAMP
|
||||
PRIMARY KEY (team_id)
|
||||
```
|
||||
|
||||
When a conversation room is shared with a team, the appservice adds it to the team's Space via `m.space.child` state events. Removing the share removes the child relationship.
|
||||
|
||||
### Agent Ghost Users
|
||||
|
||||
Each Mosaic agent is represented in Matrix as an **appservice ghost user**:
|
||||
|
||||
- Matrix user ID format: `@mosaic_agent_<agentId>:homeserver`
|
||||
- Display name: the agent's human-readable name (e.g. "Mosaic Assistant")
|
||||
- Avatar: optional, configurable per agent
|
||||
|
||||
Ghost users are registered lazily — the appservice creates the ghost on first use. Ghost users are controlled exclusively by the appservice; they cannot log in via Matrix client credentials.
|
||||
|
||||
When an agent sends a message via the gateway, the Matrix adapter sends the event using `user_id` impersonation on the appservice's client endpoint, causing the message to appear as if sent by the ghost user.
|
||||
|
||||
### Power Levels
|
||||
|
||||
Power levels in each Mosaic-managed room are set as follows:
|
||||
|
||||
| Entity | Power Level | Rationale |
|
||||
| ------------------------------------- | -------------- | -------------------------------------- |
|
||||
| Mosaic appservice bot (`@mosaic-bot`) | 100 (Admin) | Room management and moderation |
|
||||
| Human Mosaic users | 50 (Moderator) | Can kick, redact, and invite |
|
||||
| Agent ghost users | 0 (Default) | Message-only; cannot modify room state |
|
||||
|
||||
This arrangement ensures human users retain full control. An agent cannot modify room settings, kick members, or take administrative actions. Humans with moderator power can redact agent messages and intervene in ongoing conversations.
|
||||
|
||||
```
|
||||
mermaid
|
||||
graph TD
|
||||
A[Mosaic Admin] -->|invites| B[Human User]
|
||||
B -->|joins| C[Matrix Room / Conversation]
|
||||
D[Agent Ghost User] -->|sends messages to| C
|
||||
B -->|can redact/kick| D
|
||||
E[Mosaic Bot] -->|manages room state| C
|
||||
style A fill:#4a9eff
|
||||
style B fill:#4a9eff
|
||||
style D fill:#aaaaaa
|
||||
style E fill:#ff9944
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## M7-004: Conversation Multiplexing
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
A single Mosaic conversation can be accessed simultaneously from multiple surfaces: TUI, WebUI, and Matrix. The gateway is the **single source of truth** for all conversation state. Each surface is a thin client that renders gateway-owned data.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Gateway (NestJS) │
|
||||
│ │
|
||||
│ ConversationService ←→ MessageBus │
|
||||
│ │ │ │
|
||||
│ [DB: PostgreSQL] [Fanout: Valkey Pub/Sub] │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┼──────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ Socket.IO Socket.IO Matrix │ │
|
||||
│ (TUI adapter) (WebUI adapter) (appservice)│ │
|
||||
└──────────┼─────────────────────┼──────────────┘ │
|
||||
│ │ │
|
||||
CLI/TUI Browser Matrix
|
||||
Client
|
||||
```
|
||||
|
||||
### Real-Time Sync Flow
|
||||
|
||||
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
|
||||
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`.
|
||||
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
|
||||
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
|
||||
- TUI adapter: writes rendered output to the connected terminal session.
|
||||
- WebUI adapter: emits a `chat:message` Socket.IO event to all browser sessions joined to that conversation.
|
||||
- Matrix adapter: sends an `m.room.message` event to the conversation's Matrix room.
|
||||
|
||||
This ensures that a message typed in the TUI appears in the browser and in Matrix within the same round-trip latency as the Valkey fanout (typically <10 ms on co-located infrastructure).
|
||||
|
||||
### Surface-to-Transport Mapping
|
||||
|
||||
| Surface | Transport to Gateway | Fanout Transport from Gateway |
|
||||
| ------- | ------------------------------------------ | ----------------------------- |
|
||||
| TUI | HTTPS REST + SSE or WebSocket | Socket.IO over stdio proxy |
|
||||
| WebUI | Socket.IO (browser) | Socket.IO emit |
|
||||
| Matrix | Matrix Client-Server API (appservice push) | Matrix `m.room.message` send |
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
- **Messages**: Append-only. Messages are never edited in-place in Mosaic's canonical store. Matrix edit events (`m.replace`) are treated as new messages with `replyToId` pointing to the original, preserving the full audit trail.
|
||||
- **Metadata (title, tags, archived state)**: Last-write-wins. The timestamp of the most recent write wins. Concurrent metadata updates from different surfaces are serialized through `ConversationService`; the final database write reflects the last persisted value.
|
||||
- **Conversation membership**: Set-merge semantics. Adding a user from any surface is additive. Removal requires an explicit delete action and is not overwritten by concurrent adds.
|
||||
|
||||
### Session Isolation
|
||||
|
||||
Multiple TUI sessions or browser tabs connected to the same conversation receive all fanout messages independently. Each session maintains its own scroll position and local ephemeral state (typing indicator, draft text). Gateway does not synchronize ephemeral state across sessions.
|
||||
|
||||
---
|
||||
|
||||
## M7-005: Remote Auth Bridging
|
||||
|
||||
### Overview
|
||||
|
||||
Matrix users authenticate to Mosaic by linking their Matrix identity to an existing Mosaic account. There are two flows: token linking (primary) and OAuth bridge (alternative). Once linked, the Matrix session is persistent — there is no periodic login/logout cycle.
|
||||
|
||||
### Token Linking Flow
|
||||
|
||||
1. A Mosaic admin or the user themselves generates a short-lived link token via the Mosaic web UI or API (`POST /auth/channel-link-token`). The token is a cryptographically random 32-byte hex string with a 15-minute TTL stored in Valkey.
|
||||
2. The user opens a Matrix client and sends a DM to `@mosaic-bot:homeserver`.
|
||||
3. The user sends the command: `!link <token>`
|
||||
4. The appservice receives the `m.room.message` event in the DM room, extracts the token, and calls `AuthService.linkChannelIdentity({ channel: 'matrix', channelUserId: matrixUserId, token })`.
|
||||
5. `AuthService` validates the token, retrieves the associated `mosaicUserId`, and writes a row to `channel_identities`.
|
||||
6. The appservice sends a confirmation reply in the DM room and invites the now-linked user to their personal Matrix Space.
|
||||
|
||||
```
|
||||
User (Matrix) @mosaic-bot Mosaic Gateway
|
||||
│ │ │
|
||||
│ DM: !link <token> │ │
|
||||
│────────────────────▶│ │
|
||||
│ │ POST /auth/link │
|
||||
│ │─────────────────────▶│
|
||||
│ │ 200 OK │
|
||||
│ │◀─────────────────────│
|
||||
│ ✓ Linked! Joining │ │
|
||||
│ your Space now │ │
|
||||
│◀────────────────────│ │
|
||||
```
|
||||
|
||||
### OAuth Bridge Flow
|
||||
|
||||
An alternative flow for users who prefer browser-based authentication:
|
||||
|
||||
1. The Mosaic bot sends the user a Matrix message containing an OAuth URL: `https://mosaic.example.com/auth/matrix-link?state=<nonce>&matrix_user=<encoded_mxid>`
|
||||
2. The user opens the URL in a browser. If not already logged in to Mosaic, they are redirected through the standard BetterAuth login flow.
|
||||
3. On successful authentication, Mosaic records the `channel_identities` row linking `matrix_user` to the authenticated `mosaicUserId`.
|
||||
4. The gateway sends a Matrix event to the pending DM room confirming the link.
|
||||
|
||||
### Invite-Based Provisioning
|
||||
|
||||
When a Mosaic admin adds a new user account, the provisioning flow optionally associates a Matrix user ID with the new account at creation time:
|
||||
|
||||
1. Admin provides `matrixUserId` when creating the account (`POST /admin/users`).
|
||||
2. `UserService` writes the `channel_identities` row immediately.
|
||||
3. The Matrix adapter's provisioning hook fires, and the appservice:
|
||||
- Creates the user's personal Matrix Space (if not already existing).
|
||||
- Sends an invite to the Matrix user for their personal Space.
|
||||
- Sends a welcome DM from `@mosaic-bot` with onboarding instructions.
|
||||
|
||||
The invited user does not need to complete any linking step — the association is pre-established by the admin.
|
||||
|
||||
### Session Lifecycle
|
||||
|
||||
Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessions (which terminate when the terminal process exits), a Matrix user's access to their rooms remains intact as long as:
|
||||
|
||||
- Their Mosaic account is active (not suspended or deleted).
|
||||
- Their `channel_identities` row exists (link not revoked).
|
||||
- They remain members of the relevant Matrix rooms.
|
||||
|
||||
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
|
||||
|
||||
---
|
||||
|
||||
## M7-006: Agent-to-Agent Communication via Matrix
|
||||
|
||||
### Dedicated Agent Rooms
|
||||
|
||||
When two Mosaic agents need to coordinate, a dedicated Matrix room is created for their dialogue. This provides a persistent, auditable channel for structured inter-agent communication that humans can observe.
|
||||
|
||||
Room naming convention:
|
||||
|
||||
```
|
||||
#mosaic-agents-<agentA>-<agentB>:homeserver
|
||||
```
|
||||
|
||||
Where `agentA` and `agentB` are the Mosaic agent IDs sorted lexicographically (to ensure the same room is used regardless of which agent initiates). The room alias is registered by the appservice.
|
||||
|
||||
```
|
||||
agent_rooms
|
||||
room_id TEXT -- Matrix room ID
|
||||
agent_a_id TEXT -- FK to agents.id (lexicographically first)
|
||||
agent_b_id TEXT -- FK to agents.id (lexicographically second)
|
||||
created_at TIMESTAMP
|
||||
PRIMARY KEY (agent_a_id, agent_b_id)
|
||||
```
|
||||
|
||||
### Room Membership and Power Levels
|
||||
|
||||
| Entity | Power Level |
|
||||
| ---------------------------------- | ------------------------------------ |
|
||||
| Mosaic appservice bot | 100 (Admin) |
|
||||
| Human observers (invited) | 50 (Moderator, read-only by default) |
|
||||
| Agent ghost users (agentA, agentB) | 0 (Default — message send only) |
|
||||
|
||||
Humans are invited to agent rooms with a read-only intent. By convention, human messages in agent rooms are prefixed with `[HUMAN]` and treated as interrupts by the gateway. Agents are instructed (via system prompt) to pause and acknowledge human messages before resuming their dialogue.
|
||||
|
||||
### Message Format
|
||||
|
||||
Agents communicate using **structured JSON** embedded in Matrix event content. The Matrix event type is `m.room.message` with `msgtype: "m.text"` for compatibility. The structured payload is carried in a custom `mosaic.agent_message` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "m.text",
|
||||
"body": "[Agent message — see mosaic.agent_message for structured content]",
|
||||
"mosaic.agent_message": {
|
||||
"schema_version": "1.0",
|
||||
"sender_agent_id": "agent_abc123",
|
||||
"conversation_id": "conv_xyz789",
|
||||
"message_type": "request",
|
||||
"payload": {
|
||||
"action": "summarize",
|
||||
"parameters": { "max_tokens": 500 },
|
||||
"reply_to_event_id": "$previousEventId"
|
||||
},
|
||||
"timestamp_ms": 1711234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `body` field contains a human-readable fallback so the conversation is legible in any Matrix client. The structured payload is parsed exclusively by the gateway's Matrix adapter.
|
||||
|
||||
### Coordination Patterns
|
||||
|
||||
**Request/Response**: Agent A sends a `message_type: "request"` event. Agent B sends a `message_type: "response"` with `reply_to_event_id` referencing Agent A's event. The gateway correlates request/response pairs using the event IDs.
|
||||
|
||||
**Broadcast**: An agent sends a `message_type: "broadcast"` to a multi-agent room (more than two members). All agents in the room receive the event. No response is expected.
|
||||
|
||||
**Delegation**: Agent A sends a `message_type: "delegate"` with a `payload.task` object describing work to be handed off to Agent B. Agent B acknowledges with `message_type: "delegate_ack"` and later sends `message_type: "delegate_complete"` when done.
|
||||
|
||||
```
|
||||
AgentA Gateway AgentB
|
||||
│ delegate(task) │ │
|
||||
│────────────────────▶│ │
|
||||
│ │ Matrix event push │
|
||||
│ │────────────────────▶│
|
||||
│ │ delegate_ack │
|
||||
│ │◀────────────────────│
|
||||
│ │ [AgentB executes] │
|
||||
│ │ delegate_complete │
|
||||
│ │◀────────────────────│
|
||||
│ task result │ │
|
||||
│◀────────────────────│ │
|
||||
```
|
||||
|
||||
### Gateway Mediation
|
||||
|
||||
Agents do not call the Matrix Client-Server API directly. All inter-agent Matrix events are sent and received by the gateway's appservice. This means:
|
||||
|
||||
- The gateway can intercept, log, and rate-limit agent-to-agent messages.
|
||||
- Agents that are offline (no active process) still have their messages delivered; the gateway queues them and delivers on the agent's next activation.
|
||||
- The gateway can inject system messages (e.g. human interrupts, safety stops) into agent rooms without agent cooperation.
|
||||
|
||||
---
|
||||
|
||||
## M7-007: Multi-User Isolation in Matrix
|
||||
|
||||
### Space-per-Team Architecture
|
||||
|
||||
Isolation in Matrix is enforced through the Space hierarchy. Each organizational boundary in Mosaic maps to a distinct Matrix Space:
|
||||
|
||||
| Mosaic entity | Matrix Space | Visibility |
|
||||
| ----------------------------- | -------------- | ----------------- |
|
||||
| Personal workspace (per user) | Personal Space | User only |
|
||||
| Team | Team Space | Team members only |
|
||||
| Public project | (no Space) | Configurable |
|
||||
|
||||
Rooms (conversations) are placed into Spaces based on their sharing configuration. A room can appear in at most one team Space at a time. Moving a room from one team Space to another removes the `m.space.child` link from the old Space and adds it to the new one.
|
||||
|
||||
### Room Visibility Rules
|
||||
|
||||
Matrix room visibility within Conduit is controlled by:
|
||||
|
||||
1. **Join rules**: All Mosaic-managed rooms use `join_rule: invite`. Users cannot discover or join rooms without an explicit invite from the appservice.
|
||||
2. **Space membership**: Rooms appear in a Space's directory only to users who are members of that Space.
|
||||
3. **Room directory**: The server room directory is disabled for Mosaic-managed rooms (`m.room.history_visibility: shared` for team rooms, `m.room.history_visibility: invited` for personal rooms).
|
||||
|
||||
### Personal Space Defaults
|
||||
|
||||
When a user account is created (or linked to Matrix), the appservice provisions a personal Space:
|
||||
|
||||
- Space name: `<username>'s Space`
|
||||
- All conversations the user creates personally are added as children of their personal Space.
|
||||
- No other users are members of this Space by default.
|
||||
- Conversation rooms within the personal Space are only visible and accessible to the owner.
|
||||
|
||||
### Team Shared Rooms
|
||||
|
||||
When a project or conversation is shared with a team:
|
||||
|
||||
1. The appservice adds the room as a child of the team's Space (`m.space.child` state event in the Space room, `m.space.parent` state event in the conversation room).
|
||||
2. All current team members are invited to the conversation room.
|
||||
3. Newly added team members are automatically invited to all shared rooms in the team's Space by the appservice's team membership hook.
|
||||
4. If sharing is revoked, the appservice removes the `m.space.child` link and kicks all team members who joined via the team share (users who were directly invited are unaffected).
|
||||
|
||||
### Encryption
|
||||
|
||||
Encryption is optional and configured per room at creation time. Recommended defaults:
|
||||
|
||||
| Space type | Encryption default | Rationale |
|
||||
| -------------- | ------------------ | -------------------------------------- |
|
||||
| Personal Space | Enabled | Privacy-first for individual users |
|
||||
| Team Space | Disabled | Operational visibility; admin auditing |
|
||||
| Agent rooms | Disabled | Gateway must read structured payloads |
|
||||
|
||||
When encryption is enabled, the appservice's ghost users must participate in key exchange (using Matrix's Olm/Megolm protocol). The gateway holds the device keys for all ghost users it controls. This constraint means encrypted rooms require the gateway to be the E2E session holder — messages are end-to-end encrypted between human clients and gateway-held ghost device keys, not between human clients themselves.
|
||||
|
||||
### Admin Visibility
|
||||
|
||||
A Conduit server administrator can see:
|
||||
|
||||
- Room metadata: names, aliases, topic, membership list.
|
||||
- Unencrypted event content in unencrypted rooms.
|
||||
|
||||
A Conduit server administrator **cannot** see:
|
||||
|
||||
- Content of encrypted rooms (without holding a device key for a room member).
|
||||
|
||||
Mosaic does not grant gateway admin credentials to application-level admin users. The Conduit admin interface is restricted to infrastructure operators. Application-level admins manage users and rooms through the Mosaic API, which interacts with the appservice layer only.
|
||||
|
||||
### Data Retention
|
||||
|
||||
Matrix events in Mosaic-managed rooms follow Mosaic's configurable retention policy:
|
||||
|
||||
```
|
||||
room_retention_policies
|
||||
room_id TEXT -- Matrix room ID (or wildcard pattern)
|
||||
retention_days INT -- NULL = keep forever
|
||||
applies_to TEXT -- "personal" | "team" | "agent" | "all"
|
||||
created_at TIMESTAMP
|
||||
```
|
||||
|
||||
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it.
|
||||
|
||||
Default retention values:
|
||||
|
||||
| Room type | Default retention |
|
||||
| --------------------------- | ------------------- |
|
||||
| Personal conversation rooms | 365 days |
|
||||
| Team conversation rooms | 730 days |
|
||||
| Agent-to-agent rooms | 90 days |
|
||||
| System/audit rooms | 1825 days (5 years) |
|
||||
|
||||
Retention settings are configurable by Mosaic admins via the admin API and apply to both the Matrix event store and the Mosaic message store in lockstep.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Compaction observer revocation and runtime generations
|
||||
|
||||
WI-3 connects Claude and Pi compaction/session lifecycle events to the existing authenticated lease-broker state machine. It does not add a second lease store or let runtime hooks assert identity. Each observer inherits the broker-minted session, resolves the current private runtime generation, and sends the existing `revoke_lease` action over the authenticated Unix socket.
|
||||
|
||||
## Observer matrix
|
||||
|
||||
| Runtime | Lifecycle signal | Action |
|
||||
| ---------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Claude / Claudex | `PreCompact` | Revoke the current lease before compaction. A non-zero hook result blocks the lifecycle transition. |
|
||||
| Claude / Claudex | `SessionStart` with matcher `compact` | Revoke again after compacted context starts. |
|
||||
| Claude / Claudex | `SessionStart` with matcher `resume\|clear` | Atomically advance the private generation, then revoke the replacement incarnation. |
|
||||
| Pi | `session_before_compact` | Revoke before compaction; return `{ cancel: true }` if revocation cannot be confirmed. |
|
||||
| Pi | `session_compact` then the first `context` | Arm and run an independent post-compaction revoke. A failed post observer blocks later tools locally until a retry succeeds. |
|
||||
| Pi | `session_start` with reason `reload`, `new`, `resume`, or `fork` | Atomically advance the private generation, then revoke the replacement incarnation before reuse. |
|
||||
|
||||
The first observer that reaches the broker deletes pending promotion tokens and makes the lease `UNVERIFIED`. The second compaction observer is deliberate redundancy, not a prerequisite for the first. Claudex receives the same mandatory hooks in its isolated `CLAUDE_CONFIG_DIR`; hook merging preserves unrelated isolated settings and rejects malformed or symlinked settings fail-closed.
|
||||
|
||||
## Private generation authority
|
||||
|
||||
`launch-runtime.py` still registers before `exec`, preserving the kernel-authenticated PID/starttime anchor. It now also creates `generation-<broker-session>.state` beside the broker socket. The file is owner-only mode `0600` under the broker's mode-`0700` directory. Hook descendants read that file instead of relying only on an immutable inherited environment value.
|
||||
|
||||
Generation changes use an exclusive file lock, validate owner/type/mode/size, increment monotonically, truncate and write the complete new value, and `fsync` before contacting the broker. Therefore reload, new-session, resume, and fork events may retain the same PID/starttime while still becoming a new broker incarnation. The higher generation causes the broker to atomically discard prior tokens and lease authority; the replacement generation inherits no VERIFIED lease.
|
||||
|
||||
If an observer fires while broker transport is unavailable, `revoke-lease.py` advances the private generation as a local fence before returning non-zero. Every later all-tools gate reads that higher value. When the broker is reachable again, authentication of that value performs the same old-generation revocation before authorization. Pi also keeps a process-local post-compaction/rollover failure latch that blocks tool calls. An unsafe or unreadable generation file itself makes both lifecycle revocation and tool authorization fail closed.
|
||||
|
||||
## Threat contract and stopping boundary
|
||||
|
||||
### BOUNDED RESIDUAL STALE WINDOW
|
||||
|
||||
If **both** pre- and post-compaction observers are missed entirely, no revocation signal exists. During the remaining unexpired lease, **within-TTL consequential actions are allowed**. Their count and timing are **bounded by lease expiry, not by the mutator gate**. WI-3 makes no claim that it bounds mutator actions inside this stale interval. The broker's monotonic lease TTL is capped at 300 seconds; after expiry, the next consequential tool is denied with `LEASE_EXPIRED`.
|
||||
|
||||
This is the named D2-v5 T-A residual. It is distinct from an observer that fires but cannot contact the broker: the latter creates a local generation fence and fails closed. It is also distinct from T-C total rot, where the lifecycle observers and the all-tools gate are both absent or replaced. Server-side branch protection, required CI, and independent review remain the irreducible backstop for T-C.
|
||||
|
||||
| Condition | Result |
|
||||
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| Either compaction observer succeeds | Existing lease and pending promotion tokens are revoked immediately. |
|
||||
| Observer runs but broker confirmation fails | Lifecycle transition is denied where supported; local generation fence and runtime latch prevent inherited authority. |
|
||||
| Both observers are missed, lease unexpired | **ALLOWED** inside the bounded residual stale window. No within-window mutator bound is claimed. |
|
||||
| Both observers are missed, lease expired | **DENIED** by monotonic TTL expiry. |
|
||||
| Generation advances on reload/new/resume/fork | Prior incarnation revoked; replacement starts `UNVERIFIED`. |
|
||||
| Lifecycle observers and all-tools gate both fail or are removed | T-C total-hook-miss residual; protected-branch controls remain required. |
|
||||
|
||||
## T-C server-side branch-protection posture
|
||||
|
||||
The required posture is that `main` is push-blocked and PR-only-merge is **MANDATORY**, regardless
|
||||
of client-gate state. The client-side gate narrows the exposure window only; it is not the T-C
|
||||
guarantee. The server-side protected-branch configuration is the irreducible guarantee for protected
|
||||
repository actions. Status-check enforcement and approval enforcement are **RECOMMENDED**.
|
||||
|
||||
## Current-vs-required gap (recorded, not enacted)
|
||||
|
||||
The current empirical configuration is recorded here without re-probing or mutating live branch
|
||||
protection. `enable_push=False` (push-block present), so the mandatory push-block/PR-only-merge core
|
||||
holds. `require_approvals=0` (approvals not enforced), `enable_status_check=False` (status checks not
|
||||
enforced), and `block_on_official_review=False` (official review not enforced). Those recommended
|
||||
merge-quality controls are the current gap; changing them is a separate, owner-gated operations
|
||||
decision and is not enacted by this documentation change.
|
||||
|
||||
The permanent T12b/T30 acceptance case prints both required outcomes: dual-hook miss within TTL is **ALLOWED**, and the same lease after TTL is **DENIED**. Separate real-socket tests prove each Claude observer and same-PID generation rollover; Pi lifecycle tests exercise pre/post observers, all four replacement reasons, and local failure closure.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Authenticated external lease broker protocol
|
||||
|
||||
The compaction-refresh lease broker is a Linux-only, newline-framed JSON protocol over a Unix stream socket. It is runtime-neutral; M1 consumers are limited to Claude and Pi. This is an internal process boundary, not an HTTP API, so it is intentionally absent from OpenAPI.
|
||||
|
||||
The broker, never the caller, obtains `(pid, uid, gid)` from kernel `SO_PEERCRED`. It correlates the PID with `/proc/<pid>/stat` field 22 (`starttime`) and mints `session_id` on `register_anchor`. Presence of `session_id` in that request is refused even when its value is `null` or empty. Later requests must originate from the anchor or a descendant. The broker walks parent PIDs to the `(pid,starttime)` anchor and then rereads every walked PID's starttime before accepting the chain.
|
||||
|
||||
## Request and response boundary
|
||||
|
||||
Each connection carries exactly one UTF-8 JSON object followed by one newline, capped at 64 KiB. The protocol deliberately uses EOF to prove that there is exactly one frame: immediately after writing the newline, the client **MUST half-close its write side** with `shutdown(SHUT_WR)` (or Node `socket.end()`) before awaiting the response. A client that writes a newline but leaves its write side open receives no successful response; the broker's one-second connection deadline fails closed. Malformed, unterminated, multiple (including a delayed second frame), or oversized frames fail closed. Responses are one JSON object and one newline. Success has `{"ok":true,...}`; refusal has `{"ok":false,"code":"TYPED_CODE"}`. Requests are:
|
||||
|
||||
- `register_anchor`: `action`, non-negative `runtime_generation`; no `session_id` field.
|
||||
- `authenticate`: `action`, broker-minted `session_id`, non-negative `runtime_generation`.
|
||||
- `mint_token`: authenticated identity plus `binding` containing exactly `compaction_epoch`, `request_epoch`, `h_source`, `h_payload`, and `schema_version`.
|
||||
- `consume_token`: authenticated identity plus `token`.
|
||||
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
|
||||
- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned.
|
||||
- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge.
|
||||
|
||||
The daemon owns a second protected production observer socket (mode `0600`) unless a private `--test-observer-file` fixture is selected. That transport accepts only the exact `record_runtime_observation` schema after kernel `SO_PEERCRED` plus the existing anchor/ancestry authentication; it validates the pending runtime/generation before storing one finalized assistant entry for the in-process `RuntimeReceiptObserver`. It is **not** a broker request action. Claude sends its latest assistant entry from the Stop-hook transport; Pi sends only finalized `message_end` assistant content. The public broker socket continues to reject request-supplied `latest_assistant_message` in begin, observe, and complete paths.
|
||||
|
||||
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
|
||||
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority.
|
||||
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
|
||||
|
||||
A higher generation for the same anchor atomically replaces the stored incarnation and deletes all prior tokens and lease authority for that session. A lower generation is stale. Runtime descendants resolve the current generation from an owner-only, locked generation file created by the register-before-exec launcher; reload/new/resume/fork observers advance and `fsync` it before broker revocation. This supports generation replacement even when PID/starttime do not change. Tokens are 256-bit values from the operating-system cryptographic RNG and are single use. At most 256 pending tokens may be persisted; another mint fails with `TOKEN_CAPACITY` before mutation. Successful consumption deletes the token, while a replay still fails with `TOKEN_REPLAY`. Live v1 token records retain the existing `consumed: false` schema.
|
||||
|
||||
VERIFIED leases are volatile and monotonic-time bounded: broker restart, generation change, explicit observer revocation, or expiry returns the session to `UNVERIFIED`. `begin_verification` always revokes before minting a new prerequisite. `begin_recovery` reuses that exact transition and mints a new challenge, so a normal-path receipt/challenge cannot be replayed through recovery. `promote_lease` is valid only from the matching pending cycle; persistence failure rolls token and lease state back, while post-rename durability uncertainty terminates the broker. The WI-1 token is the atomic promotion prerequisite substrate.
|
||||
|
||||
## Receipt boundary and T-C residual (R1)
|
||||
|
||||
Receipt evidence is a T-A delivery/liveness prerequisite only; it cannot replace the mechanical
|
||||
mutator gate as safety authority. The receipt detects an **ABSENT** or **PREFIX-TRUNCATED** terminal
|
||||
token. A **MIDDLE-DROP** that preserves the tail is a T-C contract violation that is **NOT receipt-detectable**. It is covered by server-side protected-branch controls, **NOT** by the receipt; no category-wide receipt-detection claim is made for that tail-preserving transformation.
|
||||
|
||||
State replacement serializes and enforces the 4 MiB maximum before opening a temporary file, then uses a mode-`0600` temporary file, `fsync`, atomic rename, and parent-directory `fsync`. Every broker mutation snapshots the prior v1 state. A commit failure before rename restores that snapshot and leaves durable state unchanged. A failure after rename makes durability uncertain, so the store is poisoned without rolling memory back and the daemon terminates rather than serving with divergent state. Existing state is opened without following symlinks, must be a bounded regular file at mode `0600`, and is fully schema- and invariant-validated before use. Persisted tokens must be unconsumed, match their session's current generation, and remain within the 256-token cap. Session identity is uniquely keyed by `(anchor_pid,anchor_starttime)`; duplicate logical sessions for one anchor refuse startup. State integrity or mode failures refuse startup. The daemon does not log session IDs or tokens.
|
||||
@@ -0,0 +1,26 @@
|
||||
# WI-1 lease broker security notes
|
||||
|
||||
- Trusted identity comes only from Linux `SO_PEERCRED` plus `/proc` starttime, never request identity fields.
|
||||
- Descendant authorization is anchored to `(pid,starttime)` and uses a complete second starttime pass to fail closed on disappearance or PID-reuse races.
|
||||
- Runtime generations are monotonic per anchor; a bump revokes prior-incarnation tokens before persistence commits. WI-3 stores the live generation in an owner-only locked file so same-PID Pi reload/new/resume/fork and Claude resume/clear transitions cannot inherit a VERIFIED lease.
|
||||
- Session IDs and cycle tokens use the OS cryptographic RNG. `Math.random` and model output are not token sources.
|
||||
- Framing and persistence failures fail closed. Sensitive tokens are not logged.
|
||||
- Built-in `0700`/`0600` filesystem modes provide same-principal hardening only, not socket authenticity against the same UID. WI-1 provides no distinct-principal isolation. That stronger deployment requires an external protected proxy, ACL, or service boundary, and the boundary must preserve authenticated client identity for the broker's `SO_PEERCRED` and ancestry authorization rather than substituting a shared proxy identity.
|
||||
- WI-2 whole-class authorization denies every consequential, unknown, and custom tool while UNVERIFIED; it does not inspect shell strings or trust wrapper selection. First-class Claude/Pi, both Claudex dispatch modes, PRDY, QA remediation, coord, orchestrator, and fleet starts converge on broker register-before-exec; Claudex additionally installs the mandatory all-tools hook inside its preserved isolated config and fails closed on unsafe settings.
|
||||
- The permanent `check-runtime-launches.py` suite/CI guard scans production source for direct literal, absolute-path, process-API, command-array, and dynamic Claude/Pi launches. It has no bypass allowlist: an unrecognized launch form fails CI until routed through the common boundary.
|
||||
- WI-2 promotion consumes a WI-1 cycle token before VERIFIED becomes visible. Observer revocation, runtime-generation replacement, broker restart, and monotonic TTL expiry remove authority.
|
||||
- WI-3 wires redundant Claude `PreCompact`/`SessionStart(compact)` and Pi `session_before_compact`/post-`session_compact` `context` observers to that same revoke action. If broker confirmation fails after an observer fires, the revoker advances the private generation as a local fence; subsequent authorization revokes the stale broker incarnation before any consequential allow.
|
||||
- Dual observer absence while a lease remains live is the named **bounded residual stale window**: consequential tools remain allowed until monotonic expiry, with no claimed within-window action bound. After expiry they are denied. Total observer-plus-gate absence remains T-C.
|
||||
- Receipt observation, payload construction, and constrained recovery implementation remain later surfaces. A receipt can become a promotion prerequisite but is never the safety mechanism.
|
||||
|
||||
## Named residual: promote-lease-lost-ACK (WI-3 D2-v5)
|
||||
|
||||
A valid `promote_lease` can leave a session `VERIFIED` in the broker while the client never learns of it. This is a named, bounded D2-v5 T-A residual — an **authority-observability divergence, not an authority divergence, not an ALLOW-risk, and not a retry double-apply**. It is disclosed here, not laundered.
|
||||
|
||||
**Window — where it can occur.** The broker commits token consumption and durable `VERIFIED` state _before_ the success reply becomes visible (see the promotion order in `lease-broker-protocol.md`). The residual is confined to the interval after that commit+fsync when the broker→client reply or peer-ACK is lost — for example an extreme-contention send failure or peer disconnect after `handle()` has already mutated and persisted state (the #838 fail-closed transport path). The lease mutation is already durable broker-side; only the acknowledgement to the client is lost. No uncommitted or partially-applied state is involved: the commit either happened (and is authoritative) or it did not (and no lease exists).
|
||||
|
||||
**Fail-safe direction — the client can only under-claim.** Broker intent is the ceiling; client authority is always ≤ broker intent, never more. Client-side authority-belief is granted only by a _received_ acknowledgement; a lost acknowledgement conveys nothing, so the client cannot conclude "verified" and continues to treat itself as `UNVERIFIED` (it re-verifies or recovers). If the client retries `promote_lease` with the same token, the token is already consumed and the broker rejects the retry (`PROMOTION_TOKEN_MISMATCH` / `INVALID_LEASE_TRANSITION`); there is no double-apply. The committed `VERIFIED` state the broker holds is authority the lease _legitimately earned_ from a real promotion — the broker authorizing consequential tools under it is correct, not inflation. Divergence is therefore strictly toward _less_ client authority than the broker granted; it never produces authority the broker did not grant.
|
||||
|
||||
**Bound — TTL plus the observer/gen-bump revoke backstop, self-healing.** The orphaned `VERIFIED` lease is indistinguishable to the broker from any other legitimately verified lease, so the identical D2-v5 revocation backstops dispose of it: any compaction observer (`PreCompact` / `SessionStart(compact)` for Claude; `session_before_compact` / post-`session_compact` `context` for Pi), any same-PID runtime-generation bump (reload/new/resume/fork), broker restart, or monotonic-time expiry returns the session to `UNVERIFIED`. Monotonic TTL expiry (capped at 300 seconds) is **unconditional** — it requires no observer at all — so the maximum exposure of the orphaned lease is one TTL, ≤ 300 s, after which the next consequential tool is denied with `LEASE_EXPIRED`. Any observer that fires shortens the window further. The residual self-heals: "≥1 observer fires OR expiry ⇒ revoke" catches the lost-ACK lease on the same terms as every other stale lease. As with the dual-observer-miss stale window, WI-3 makes no claim that the mutator gate bounds actions inside the residual interval; the interval is bounded by TTL and the revoke backstop, and the server-side branch-protection / required-CI / independent-review line remains the irreducible backstop for protected-repository mutations.
|
||||
|
||||
Coordinator security review must rerun the real socket/peercred and mutator-gate acceptance suites on an unrestricted Linux runner and obtain the mandated independent Opus-SECREV review before integration.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||
|
||||
## Boundary
|
||||
|
||||
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
|
||||
|
||||
```text
|
||||
(tenant_id, logical_agent_id, binding_id)
|
||||
```
|
||||
|
||||
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
|
||||
|
||||
## Durable lease model
|
||||
|
||||
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
|
||||
|
||||
- an opaque lease UUID;
|
||||
- connector ID and normalized allowed scopes;
|
||||
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
|
||||
- acquired, heartbeat, expiry, release, and update timestamps.
|
||||
|
||||
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
|
||||
|
||||
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
|
||||
|
||||
## Execution grants
|
||||
|
||||
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
|
||||
|
||||
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
|
||||
|
||||
- grants not minted by the current gateway process (including cloned/forged objects);
|
||||
- expired grants or leases;
|
||||
- released leases;
|
||||
- stale epochs or replaced connector/lease UUIDs;
|
||||
- missing/cross-tenant/cross-agent/cross-binding leases;
|
||||
- scopes not authorized by both grant and current lease.
|
||||
|
||||
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
|
||||
|
||||
## Concurrency and side-effect rule
|
||||
|
||||
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
|
||||
|
||||
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
|
||||
|
||||
## Extension boundary
|
||||
|
||||
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Whole mutator-class lease gate
|
||||
|
||||
WI-2 adds the framework-native authorization boundary for Claude (including the supported Claudex overlay) and Pi. Every runtime-reported tool name reaches the lease broker before execution. The gate classifies capabilities by the whole tool class; it never parses a Bash command to decide whether that particular string looks read-only.
|
||||
|
||||
## Default-deny policy
|
||||
|
||||
While a session is not VERIFIED, only these exact classes are allowed:
|
||||
|
||||
- Claude: `Read`, `Grep`, `Glob`, `Ls`, `Find`
|
||||
- Pi: `read`, `grep`, `find`, `ls`
|
||||
- Both runtimes: the fixed `mosaic_context_recover` primitive
|
||||
|
||||
Every other built-in, unknown tool, and custom/MCP tool is consequential by default and is denied. This includes Claude `Bash`, `Edit`, `Write`, and `NotebookEdit`, plus Pi `bash`, `edit`, and `write`. A compromised model therefore cannot bypass Mosaic wrappers by selecting raw `git`, `curl`, `kubectl`, provider, deployment, or filesystem commands inside a generic mutator—the generic mutator itself is blocked before its input executes.
|
||||
|
||||
## Broker-owned transition order
|
||||
|
||||
The authenticated broker is the sole lease writer:
|
||||
|
||||
1. `begin_verification` revokes existing authority and pending tokens first, then records `PENDING_VERIFICATION` and mints one WI-1 single-use promotion token bound to the exact cycle.
|
||||
2. `promote_lease` accepts only that session/generation/binding/token combination.
|
||||
3. Token consumption commits before the volatile lease becomes VERIFIED. Promotion is last and cannot be reached directly from UNVERIFIED.
|
||||
4. `revoke_lease`, a runtime-generation increase, broker restart, or monotonic expiry removes mutator authority.
|
||||
|
||||
The initial TTL is capped at the ratified 300-second maximum. A caller may request a shorter positive TTL but cannot lengthen the maximum. WI-3 installs the [compaction observer and generation lifecycle](compaction-revocation.md). Dual compaction-hook miss within an unexpired lease remains the ratified bounded T-A residual: consequential tools are allowed until expiry, with no claimed within-window action bound; once either observer revokes or TTL expires, the next consequential tool is denied.
|
||||
|
||||
A receipt is only a future promotion prerequisite. It is not an obedience, residency, or safety proof and never replaces this mechanical gate.
|
||||
|
||||
## Runtime adapters
|
||||
|
||||
`launch-runtime.py` registers itself with the broker and then `exec`s Claude or Pi so PID/starttime remain the authenticated parent anchor. It exports the broker-minted session ID and an owner-only generation-file reference to descendants; lifecycle hooks advance that file for same-PID replacement generations.
|
||||
|
||||
- Claude installs `mutator-gate.py` as an all-tools (`.*`) `PreToolUse` hook.
|
||||
- `mosaic claudex` and `mosaic yolo claudex` preserve their isolated `CLAUDE_CONFIG_DIR`, merge the mandatory hook into that isolated `settings.json`, and use the same register-before-exec launcher. Malformed or symlinked isolated settings deny launch.
|
||||
- Pi invokes the same executable from its `tool_call` handler.
|
||||
|
||||
The executable submits the runtime's actual tool name to `authorize_tool`. Missing identity, malformed input/reply, timeout, broker unavailability, or denial exits with status 2 and blocks fail-closed.
|
||||
|
||||
## Runtime-launch choke-point and permanent guard
|
||||
|
||||
Every repository-owned Claude/Pi launch entry converges on `launch-runtime.py`, either directly or through `mosaic` → `execLeaseGatedRuntime`. PRDY init/update and QA remediation invoke the wrapper directly so their existing prompts, dangerous-permission behavior, working directory, and environment survive without skipping broker registration. The raw Claude `--dangerously-skip-permissions` primitive is owned only by `launch-runtime.py`; callers request semantic `--dangerous` mode, and the wrapper validates Claude before injecting the primitive. `@mosaicstack/coord` rewrites direct Claude commands to `mosaic claude` and rejects unknown custom Claude launchers.
|
||||
|
||||
`check-runtime-launches.py` is the permanent completeness guard. It scans production shell, TypeScript/JavaScript, Python, and data launch definitions under `packages/`, `apps/`, `plugins/`, and `tools/`; direct literal, absolute-path, process-API, dynamic, command-substitution, `eval`, and variable-execution runtime launches fail. Shell comments are stripped with quote awareness, wrapper prefixes are tokenized with `shlex`, and only an invocation in command position with `--runtime` before the command separator is gated. Literal and tracked-variable command tokens use one terminal resolver after any nesting of `exec`, `command`, `nohup`, or `env` plus assignments. A direct command always wins over an inert marker on the same line. Independently, the raw dangerous primitive anywhere outside the choke-point is RED.
|
||||
|
||||
The command parser is a best-effort CI defense, not a complete shell interpreter. Alias/function redefinition, sourced commands, generated scripts, and encoded pipelines are intentionally residual rather than an invitation to chase an unbounded shell language. Two runtime controls backstop that residual surface: primitive ownership rejects a dangerous launch even when command identity is alias-indirected, and Claude's global `.*` `PreToolUse` hook invokes the broker gate for non-dangerous launches. Without `MOSAIC_LEASE_SESSION_ID`, representative read, mutator, and custom/MCP tools all fail closed with `GATE_UNAVAILABLE`. Hook absence or replacement remains in the documented T-C boundary.
|
||||
|
||||
### Parser stopping criterion
|
||||
|
||||
- **A — realistic parser matrix:** comments, inert strings/assignments, heredocs, continuations, chained commands, command substitution, `eval`, bare tracked variables, and quoted/unquoted tracked variables behind `exec`, `command`, `nohup`, or `env` are permanent RED regressions. Prefix-variable forms are covered in both multiline and same-line assignment shapes.
|
||||
- **B — residual backstops:** a dangerous alias-indirected launch is RED solely through primitive anchoring; a parser-missed non-dangerous alias launch is paired with an acceptance test proving the global all-tools hook denies every representative tool class as `GATE_UNAVAILABLE` without a lease.
|
||||
- **C — independent fresh review:** the parser class is considered complete only when reviewers find no new non-overlapping realistic evasion on the exact head. A and B are repository evidence; C is supplied by the fresh review round.
|
||||
|
||||
All three layers are load-bearing and complementary. The guard is mandatory in `@mosaicstack/mosaic`'s test script, so root CI fails on a future realistic bypass. Real-socket tests separately prove PRDY init/update and QA receive broker sessions and deny an unverified mutator.
|
||||
|
||||
The live inventory is emitted by:
|
||||
|
||||
```bash
|
||||
python3 packages/mosaic/framework/tools/lease-broker/check-runtime-launches.py --root . --json
|
||||
```
|
||||
|
||||
| Production launch family | Gated entries |
|
||||
| ------------------------------------------------------ | ------------: |
|
||||
| `@mosaicstack/coord` default/configured Claude command | 2 |
|
||||
| Fleet runtime start | 1 |
|
||||
| QA remediation + generated QA command | 2 |
|
||||
| Orchestrator command construction/session launches | 3 |
|
||||
| PRDY init/update | 2 |
|
||||
| Mosaic Claude/Pi/Claudex adapter and wrapper boundary | 4 |
|
||||
| **Total** | **14 / 14** |
|
||||
|
||||
## Assurance boundary
|
||||
|
||||
This closes T-A after an observer fires or lease expiry and T-B for in-runtime tool calls. Hook/extension absence, a runtime executing outside the gated launcher, ptrace/same-UID broker replacement, and other fully rotted behavior remain T-C. Server-side branch protection and required PR review/CI remain the irreducible line for protected repository mutations.
|
||||
Reference in New Issue
Block a user