From 8551d726b5d915f38508d27d55f3660ef90ce731 Mon Sep 17 00:00:00 2001 From: Patrick Sharp Date: Fri, 3 Jul 2026 22:57:04 -0700 Subject: [PATCH 1/2] feat: single shared presence topic, topic post patterns, dynamic tool descriptions Presence rework and topic-config extensions, all within bridge-core (no seam change, no plugin changes, conformance suites untouched): - Presence now announces on ONE shared topic (presence.topic, default parley-presence) instead of a derived -parley-presence stream per allowlisted topic. The record is v2 and carries the emitter's subscribed topics, so parley_list_users still reports liveness per topic while a human mutes a single topic on real chat backends. - Heartbeat default 30s -> 10min; ttl_ms is optional and defaults to 3x the heartbeat via a Zod transform. - Chat/reactive-only instances set presence.enabled: false (wired into the remote-chat example configs). - post_topics: full-match regex patterns that extend post/fetch ONLY; subscribe, catch-up, presence, and list_users-default still use the explicit topics list. The Allowlist grows a two-dimensional check plus a reserved guard so no pattern can target the presence topic and spoof the roster. - Tool descriptions interpolate the configured topics/patterns at registration, and the topic param carries a JSON-Schema enum when no pattern widens the set, so an agent discovers what it may post to with no extra call. Note: the presence wire format is v1->v2; old and new bridges are mutually invisible in parley_list_users (accepted pre-1.0). Co-Authored-By: Claude Fable 5 --- DESIGN.md | 42 ++++-- README.md | 21 ++- .../multi-session/matrix/remote-chat.yaml | 4 + examples/multi-session/nats/remote-chat.yaml | 4 + examples/multi-session/redis/remote-chat.yaml | 4 + .../multi-session/sqlite/remote-chat.yaml | 4 + examples/multi-session/xmpp/remote-chat.yaml | 4 + examples/self-host-remote/parley.config.yaml | 6 + packages/bridge-core/src/allowlist.test.ts | 45 +++++++ packages/bridge-core/src/allowlist.ts | 72 +++++++++-- packages/bridge-core/src/config.test.ts | 45 +++++++ packages/bridge-core/src/config.ts | 61 +++++++-- .../bridge-core/src/engine/presence.test.ts | 64 +++++---- packages/bridge-core/src/engine/presence.ts | 62 +++++---- packages/bridge-core/src/index.ts | 6 +- packages/bridge-core/src/transport/http.ts | 10 +- .../src/transport/presence-loop.test.ts | 35 +++-- .../src/transport/presence-loop.ts | 32 ++--- .../bridge-core/src/transport/stdio-bridge.ts | 10 +- .../bridge-core/src/transport/tools.test.ts | 121 ++++++++++++++++-- packages/bridge-core/src/transport/tools.ts | 119 +++++++++++------ 21 files changed, 589 insertions(+), 182 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f52754a..b0f3708 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -214,15 +214,22 @@ dropped or duplicated push is harmless; core reconciles against the store via `f - **Catch-up scope.** `fetchRecent` is **single-topic**; core loops once per configured topic. Handle-based catch-up = resolve handle → set of topics → loop. - **Presence / liveness.** A bridge announces itself by posting `hello` / `heartbeat` / - `goodbye` to a **derived presence stream** (a real topic + a reserved suffix), isolated from - the real topic: presence streams are **never subscribed and never enter catch-up / dedup**, so - heartbeats never surface as `` events or pollute durable history. The - `parley_list_users` tool reconstructs "who is live" from `fetchRecent` over those streams plus - a TTL window — so it works **identically on every backend with no new seam method**, and lists - an idle instance that has never posted. TTL reclaims crashed instances; `goodbye` is a - best-effort fast-path. This is **Parley-participant liveness, not a human directory** — a human - in a native chat client appears only once they send a real message. Powered above the seam by - `post`/`fetchRecent`; knobs in §11 (`presence`). + `goodbye` to **one shared presence topic** (`presence.topic`, default `parley-presence`), and + each beat carries the instance's subscribed topics. This is isolated from real topics: the + presence topic is **never subscribed and never enters catch-up / dedup**, so heartbeats never + surface as `` events or pollute durable history — and because it is a single stream, a + human on a real chat backend mutes **one** topic instead of one per context. It is also + **reserved**: no `post` / `fetch_recent` (nor any `post_topics` pattern, §14) may target it, so + a peer cannot spoof the roster. The `parley_list_users` tool reconstructs "who is live" from + `fetchRecent` over that one topic plus a TTL window — so it works **identically on every backend + with no new seam method**, reports each instance's subscribed topics, and lists an idle instance + that has never posted. TTL reclaims crashed instances; `goodbye` is a best-effort fast-path. + This is **Parley-participant liveness, not a human directory** — a human in a native chat client + appears only once they send a real message. A reactive-only front door (the chat instance) + cannot receive `` pushes and can set `presence.enabled: false` to stay silent. Powered + above the seam by `post`/`fetchRecent`; knobs in §11 (`presence`). Keep `presence.topic` + consistent across a deployment — bridges announcing on different presence topics cannot see each + other. --- @@ -380,9 +387,11 @@ A single config object drives the bridge; sane defaults everywhere. Illustrative backend: local-sqlite # local-sqlite | local-redis | matrix | xmpp | nats identity: handle: "ctx-payments" # this instance's logical handle -topics: # subscribe to / catch up on (one fetchRecent call each) +topics: # subscribe to / catch up on (one fetchRecent call each) — THE ALLOWLIST - "ctx-payments" - "ctx-payments-reviews" +post_topics: # OPTIONAL extra topics allowed for post/fetch only, as full-match regexes + - "ctx-payments-.*" # (never subscribed/caught-up; the presence topic can never be matched) catchup: on_start: true limit: 100 @@ -390,9 +399,10 @@ live_push: enabled: false # Code only; chat leaves this off mention_filter: false # true = only surface messages mentioning `handle` presence: # announce hello/heartbeat/goodbye; powers parley_list_users (§7) - enabled: true - heartbeat_ms: 30000 - ttl_ms: 90000 # a handle is "live" if its last beat is within this window + enabled: true # reactive-only front doors (chat) can set this false + topic: "parley-presence" # ONE shared topic all bridges announce on; mute this one to hide presence + heartbeat_ms: 600000 # 10 min — agents stay subscribed a long time + ttl_ms: 1800000 # a handle is "live" if its last beat is within this window; default 3× heartbeat_ms permissions: skip_permissions: false # DANGEROUS; sandbox-only; default off backend_config: # opaque to core; passed to the plugin @@ -528,7 +538,11 @@ promise: *implement five methods, get a Claude bridge for your platform.* ## 14. Security (designed in, not bolted on) - **Topic allowlist.** The bridge only subscribes to / catches up on an explicit list of - topics. No wildcard-everything by default. + topics (`topics`). No wildcard-everything by default. `post_topics` optionally extends + **post/fetch only** with full-match regex patterns (for a chat instance posting to ad-hoc + topics) — it never widens subscribe/catch-up/presence, which stay the explicit list. The + presence topic is **reserved**: no pattern, however broad, makes it postable/fetchable, so a + peer cannot spoof the presence roster. - **Inbound is untrusted.** A backend message becomes agent context; treat it as untrusted input, never as privileged instruction. With spawn deferred, worst case is "writes into a live session," but the prompt-injection surface concentrates here — keep backends private diff --git a/README.md b/README.md index 977b594..1884c52 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ identity: { handle: "agent" } topics: ["ctx-demo"] catchup: { on_start: true, limit: 100 } live_push: { enabled: true, mention_filter: false } -presence: { enabled: true, heartbeat_ms: 30000, ttl_ms: 90000 } # powers parley_list_users +# One shared presence topic powers parley_list_users; ttl_ms defaults to 3× heartbeat_ms. +presence: { enabled: true, topic: "parley-presence", heartbeat_ms: 600000 } backend_config: db_path: "./parley-demo.db" poll_interval_ms: 500 @@ -131,11 +132,19 @@ await app.listen(3000); - **Chat handoff.** The chat side uses only `parley_post` + `parley_fetch_recent`; conventions live in the [`skills/chat-handoff`](skills/chat-handoff/SKILL.md) skill. One seam, one write path — do **not** install a separate backend-specific MCP in chat. -- **Discover who's live.** Each bridge announces itself with presence heartbeats, so - `parley_list_users` (optional glob, e.g. `claude-*`) reports who is on the bus right now — - including an idle agent that hasn't posted — to pick a hand-off target. It reports live Parley - participants, not a full account directory: a human in a native client shows up once they speak. - Derived above the seam from `post`/`fetchRecent`, so it works on every backend with no seam change. +- **Discover who's live.** Each bridge announces itself with presence heartbeats on one shared + topic (`presence.topic`, default `parley-presence`), so `parley_list_users` (optional glob, e.g. + `claude-*`) reports who is on the bus right now — including an idle agent that hasn't posted, with + the topics each subscribes to — to pick a hand-off target. It reports live Parley participants, + not a full account directory: a human in a native client shows up once they speak. Because it's a + single stream, you mute **one** topic to hide presence on a real chat backend; a reactive-only + chat instance that can't receive pushes can set `presence.enabled: false` to skip heartbeats + entirely. Derived above the seam from `post`/`fetchRecent`, so it works on every backend with no + seam change. +- **Post beyond the allowlist.** `topics` is the subscribe/catch-up allowlist; add `post_topics` + (full-match regexes) to let an instance `parley_post`/`parley_fetch_recent` on ad-hoc topics it + doesn't subscribe to. The configured topics (and any post patterns) are surfaced in the MCP tool + descriptions, so an agent discovers what it may post to without a separate call. ## Backends diff --git a/examples/multi-session/matrix/remote-chat.yaml b/examples/multi-session/matrix/remote-chat.yaml index f5ebd7f..9bba3f5 100644 --- a/examples/multi-session/matrix/remote-chat.yaml +++ b/examples/multi-session/matrix/remote-chat.yaml @@ -3,11 +3,15 @@ identity: topics: - "ctx-payments" - "ctx-infra" +# post_topics: # OPTIONAL: let chat post to ad-hoc topics it doesn't subscribe to +# - "ctx-.*" # full-match regexes; post/fetch only, never subscribed catchup: on_start: false limit: 100 live_push: enabled: false +presence: + enabled: false # reactive-only chat can't receive pushes → skip heartbeats (DESIGN §7) permissions: skip_permissions: false diff --git a/examples/multi-session/nats/remote-chat.yaml b/examples/multi-session/nats/remote-chat.yaml index f8cebee..267c3d3 100644 --- a/examples/multi-session/nats/remote-chat.yaml +++ b/examples/multi-session/nats/remote-chat.yaml @@ -3,11 +3,15 @@ identity: topics: - "ctx-payments" - "ctx-infra" +# post_topics: # OPTIONAL: let chat post to ad-hoc topics it doesn't subscribe to +# - "ctx-.*" # full-match regexes; post/fetch only, never subscribed catchup: on_start: false limit: 100 live_push: enabled: false +presence: + enabled: false # reactive-only chat can't receive pushes → skip heartbeats (DESIGN §7) permissions: skip_permissions: false diff --git a/examples/multi-session/redis/remote-chat.yaml b/examples/multi-session/redis/remote-chat.yaml index 8a5e4e1..ade3c60 100644 --- a/examples/multi-session/redis/remote-chat.yaml +++ b/examples/multi-session/redis/remote-chat.yaml @@ -3,11 +3,15 @@ identity: topics: - "ctx-payments" - "ctx-infra" +# post_topics: # OPTIONAL: let chat post to ad-hoc topics it doesn't subscribe to +# - "ctx-.*" # full-match regexes; post/fetch only, never subscribed catchup: on_start: false limit: 100 live_push: enabled: false +presence: + enabled: false # reactive-only chat can't receive pushes → skip heartbeats (DESIGN §7) permissions: skip_permissions: false diff --git a/examples/multi-session/sqlite/remote-chat.yaml b/examples/multi-session/sqlite/remote-chat.yaml index 879e56c..a9dd752 100644 --- a/examples/multi-session/sqlite/remote-chat.yaml +++ b/examples/multi-session/sqlite/remote-chat.yaml @@ -4,11 +4,15 @@ identity: topics: - "ctx-payments" - "ctx-infra" +# post_topics: # OPTIONAL: let chat post to ad-hoc topics it doesn't subscribe to +# - "ctx-.*" # full-match regexes; post/fetch only, never subscribed catchup: on_start: false # chat reads on demand via parley_fetch_recent, not on connect limit: 100 live_push: enabled: false # chat cannot receive pushes +presence: + enabled: false # reactive-only chat can't receive pushes → skip heartbeats (DESIGN §7) permissions: skip_permissions: false diff --git a/examples/multi-session/xmpp/remote-chat.yaml b/examples/multi-session/xmpp/remote-chat.yaml index e62430a..c96989f 100644 --- a/examples/multi-session/xmpp/remote-chat.yaml +++ b/examples/multi-session/xmpp/remote-chat.yaml @@ -3,11 +3,15 @@ identity: topics: - "ctx-payments" - "ctx-infra" +# post_topics: # OPTIONAL: let chat post to ad-hoc topics it doesn't subscribe to +# - "ctx-.*" # full-match regexes; post/fetch only, never subscribed catchup: on_start: false limit: 100 live_push: enabled: false +presence: + enabled: false # reactive-only chat can't receive pushes → skip heartbeats (DESIGN §7) permissions: skip_permissions: false diff --git a/examples/self-host-remote/parley.config.yaml b/examples/self-host-remote/parley.config.yaml index 1171e04..4fb3470 100644 --- a/examples/self-host-remote/parley.config.yaml +++ b/examples/self-host-remote/parley.config.yaml @@ -9,6 +9,9 @@ identity: topics: - "ctx-handoff" +# post_topics: # OPTIONAL: extra topics allowed for post/fetch only, as full-match regexes +# - "ctx-.*" # (never subscribed/caught-up; the presence topic can never be matched) + catchup: on_start: false # remote/chat reads context on demand via the fetch_recent tool limit: 100 @@ -16,6 +19,9 @@ catchup: live_push: enabled: false # chat cannot receive pushes (§8) +presence: + enabled: false # reactive-only chat can't receive pushes → skip heartbeats (DESIGN §7) + permissions: skip_permissions: false diff --git a/packages/bridge-core/src/allowlist.test.ts b/packages/bridge-core/src/allowlist.test.ts index a7c9296..9882f39 100644 --- a/packages/bridge-core/src/allowlist.test.ts +++ b/packages/bridge-core/src/allowlist.test.ts @@ -17,4 +17,49 @@ describe('Allowlist', () => { it('exposes the branded topic set for subscribe', () => { expect(allow.topics().sort()).toEqual(['ctx-payments', 'ctx-payments-reviews']); }); + + it('exposes no patterns by default', () => { + expect(allow.patterns()).toEqual([]); + }); +}); + +describe('Allowlist post patterns', () => { + const allow = new Allowlist(['ctx-a'], { postPatterns: ['ctx-.*', 'exp/[a-z]+'] }); + + it('accepts a pattern-matched topic for post/fetch (full-match anchored)', () => { + expect(allow.has('ctx-anything')).toBe(true); + expect(allow.assert('exp/beta')).toBe('exp/beta'); + }); + + it('anchors patterns — a partial match does not pass', () => { + expect(allow.has('x-ctx-a')).toBe(false); + expect(allow.has('ctx-a-suffix')).toBe(true); // ctx-.* still matches this + expect(allow.has('exp/Beta')).toBe(false); // [a-z]+ excludes uppercase + }); + + it('does NOT widen the explicit topic set (subscribe/catch-up stay exact)', () => { + expect(allow.topics()).toEqual(['ctx-a']); + }); + + it('round-trips the raw pattern sources', () => { + expect(allow.patterns()).toEqual(['ctx-.*', 'exp/[a-z]+']); + }); +}); + +describe('Allowlist reserved topics', () => { + it('refuses a reserved topic on post/fetch even when a pattern would match it', () => { + const allow = new Allowlist(['ctx-a'], { + postPatterns: ['.*'], + reserved: ['parley-presence'], + }); + expect(allow.has('parley-presence')).toBe(false); + expect(() => allow.assert('parley-presence')).toThrow(TopicNotAllowedError); + expect(allow.has('ctx-a')).toBe(true); // the broad pattern still allows non-reserved topics + }); + + it('throws when an explicit topic is also reserved (config error)', () => { + expect(() => new Allowlist(['parley-presence'], { reserved: ['parley-presence'] })).toThrow( + TopicNotAllowedError, + ); + }); }); diff --git a/packages/bridge-core/src/allowlist.ts b/packages/bridge-core/src/allowlist.ts index 8a2fb91..fae736c 100644 --- a/packages/bridge-core/src/allowlist.ts +++ b/packages/bridge-core/src/allowlist.ts @@ -1,3 +1,4 @@ +import type { ParleyConfig } from './config.js'; import { asTopic, type Topic } from './message.js'; /** Raised when a tool call or subscription targets a topic outside the allowlist. */ @@ -8,35 +9,82 @@ export class TopicNotAllowedError extends Error { } } +/** Options extending the exact allowlist with a post/fetch pattern dimension and reserved topics. */ +export interface AllowlistOptions { + /** + * Regex sources additionally allowed for `post`/`fetch_recent` (NOT subscribe/catch-up). + * Each is compiled full-match anchored (`^(?:src)$`). Invalid sources should be rejected by + * config validation first; the constructor throws if one reaches here. + */ + postPatterns?: readonly string[]; + /** Topics never allowed via ANY path, even if matched by a pattern (the presence topic). */ + reserved?: readonly string[]; +} + /** - * The topic allowlist (DESIGN §14). The bridge only subscribes to / catches up on / posts to - * an explicit list of topics — `config.topics` IS the allowlist; there is no - * wildcard-everything default. A single guard (`assert`) gates every tool entry point - * (`post`/`reply`/`fetch_recent`) and is the only set `subscribe` iterates. + * The topic allowlist (DESIGN §14). Two dimensions: + * + * - the EXPLICIT list (`config.topics`) — the only set `subscribe`/catch-up iterate and the + * default scope of `parley_list_users`; exposed via {@link topics}; + * - the POST/FETCH set — the explicit list PLUS any `post_topics` pattern match; gates + * `post`/`reply`/`fetch_recent` via {@link has}/{@link assert}. * - * Inbound is untrusted (DESIGN §14): message content becomes agent context and is never - * treated as a privileged instruction. A reply always targets the inbound topic, which is - * subscribed and therefore already allowed. + * There is no wildcard-everything default: patterns are opt-in and never widen subscribe. A + * `reserved` topic (the presence topic) is refused on BOTH dimensions — a broad pattern can + * never make it postable/fetchable, so a peer cannot spoof the presence roster. + * + * Inbound is untrusted (DESIGN §14): message content becomes agent context and is never treated + * as a privileged instruction. A reply always targets the inbound topic, which is subscribed + * (thus in the explicit list) and therefore already allowed. */ export class Allowlist { private readonly allowed: Set; + private readonly reserved: Set; + private readonly patternSources: readonly string[]; + private readonly patternRegexes: RegExp[]; - constructor(topics: Iterable) { + constructor(topics: Iterable, opts: AllowlistOptions = {}) { this.allowed = new Set(topics); + this.reserved = new Set(opts.reserved ?? []); + for (const t of this.allowed) { + if (this.reserved.has(t)) throw new TopicNotAllowedError(t); // reserved ∩ explicit is a config error + } + this.patternSources = opts.postPatterns ?? []; + this.patternRegexes = this.patternSources.map((src) => new RegExp(`^(?:${src})$`)); } + /** True if the topic may be posted to / fetched: explicit OR pattern match, never reserved. */ has(topic: string): boolean { - return this.allowed.has(topic); + if (this.reserved.has(topic)) return false; + if (this.allowed.has(topic)) return true; + return this.patternRegexes.some((re) => re.test(topic)); } - /** Return the branded Topic if allowed; otherwise throw {@link TopicNotAllowedError}. */ + /** Return the branded Topic if postable/fetchable; otherwise throw {@link TopicNotAllowedError}. */ assert(topic: string): Topic { - if (!this.allowed.has(topic)) throw new TopicNotAllowedError(topic); + if (!this.has(topic)) throw new TopicNotAllowedError(topic); return asTopic(topic); } - /** Every allowed topic, branded. */ + /** The EXPLICIT topics only, branded — what subscribe/catch-up/presence iterate. */ topics(): Topic[] { return [...this.allowed].map(asTopic); } + + /** The raw `post_topics` pattern sources (for surfacing in tool descriptions). */ + patterns(): string[] { + return [...this.patternSources]; + } +} + +/** + * Build the Allowlist a bridge runs with: the explicit `topics`, extended for post/fetch by the + * `post_topics` patterns, with the presence topic reserved so no pattern can spoof the roster. + * Single source of truth shared by every composition root (stdio + remote HTTP). + */ +export function allowlistFor(cfg: ParleyConfig): Allowlist { + return new Allowlist(cfg.topics, { + postPatterns: cfg.post_topics, + reserved: [cfg.presence.topic], + }); } diff --git a/packages/bridge-core/src/config.test.ts b/packages/bridge-core/src/config.test.ts index 1ff8d2e..9afff40 100644 --- a/packages/bridge-core/src/config.test.ts +++ b/packages/bridge-core/src/config.test.ts @@ -9,6 +9,51 @@ describe('config loader', () => { expect(cfg.live_push).toEqual({ enabled: false, mention_filter: false }); expect(cfg.permissions.skip_permissions).toBe(false); expect(cfg.backend_config).toEqual({}); + expect(cfg.post_topics).toEqual([]); + // Presence defaults: single shared topic, 10-min heartbeat, TTL = 3× heartbeat. + expect(cfg.presence).toEqual({ + enabled: true, + topic: 'parley-presence', + heartbeat_ms: 600_000, + ttl_ms: 1_800_000, + }); + }); + + it('derives presence.ttl_ms from an explicit heartbeat, but honors an explicit ttl', () => { + const derived = parseConfig({ + identity: { handle: 'h' }, + topics: ['a'], + presence: { heartbeat_ms: 60_000 }, + }); + expect(derived.presence.ttl_ms).toBe(180_000); + const pinned = parseConfig({ + identity: { handle: 'h' }, + topics: ['a'], + presence: { heartbeat_ms: 60_000, ttl_ms: 500_000 }, + }); + expect(pinned.presence.ttl_ms).toBe(500_000); + }); + + it('accepts post_topics and rejects an uncompilable regex', () => { + const cfg = parseConfig({ identity: { handle: 'h' }, topics: ['a'], post_topics: ['ctx-.*'] }); + expect(cfg.post_topics).toEqual(['ctx-.*']); + expect(() => + parseConfig({ identity: { handle: 'h' }, topics: ['a'], post_topics: ['ctx-('] }), + ).toThrow(/invalid regex/); + }); + + it('rejects an explicit topic that collides with the presence topic', () => { + expect(() => + parseConfig({ identity: { handle: 'h' }, topics: ['parley-presence'] }), + ).toThrow(/reserved for presence/); + // Also when the presence topic is customized. + expect(() => + parseConfig({ + identity: { handle: 'h' }, + topics: ['a', 'live'], + presence: { topic: 'live' }, + }), + ).toThrow(/reserved for presence/); }); it('merges partial nested objects with per-field defaults', () => { diff --git a/packages/bridge-core/src/config.ts b/packages/bridge-core/src/config.ts index aa39fb7..86c8210 100644 --- a/packages/bridge-core/src/config.ts +++ b/packages/bridge-core/src/config.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +import { DEFAULT_PRESENCE_TOPIC } from './engine/presence.js'; /** * Remote-mode auth via an external OIDC IdP (e.g. Keycloak) — the delegated resource-server @@ -54,8 +55,11 @@ export type AuthConfig = z.infer; /** * The single config object that drives a bridge (DESIGN §11). Sane defaults everywhere. * `backend_config` is opaque to core and passed verbatim to the plugin's `connect()`. + * + * Post-parse cross-field checks (regex compilation, presence-topic collision) live in the + * `.superRefine` on {@link ConfigSchema} below — they need the whole object. */ -export const ConfigSchema = z.object({ +const ConfigObject = z.object({ /** Which backend plugin to load. v0.1 ships only local-sqlite. */ backend: z.string().default('local-sqlite'), /** Read-state namespace; defaults to identity.handle. Distinct sessions sharing a handle @@ -68,6 +72,14 @@ export const ConfigSchema = z.object({ }), /** Topics to subscribe to / catch up on. THIS IS THE ALLOWLIST (DESIGN §14). */ topics: z.array(z.string().min(1)).min(1), + /** + * Extra topics allowed for `post`/`fetch_recent` ONLY, as full-match regex sources (anchored + * `^(?:…)$` at compile time). Lets a chat instance post to ad-hoc topics without listing each + * one. These are NEVER subscribed / caught up on / announced in presence — that stays the + * explicit `topics` list. The presence topic can never be matched (it is reserved). Invalid + * regexes are rejected at load (DESIGN §14). + */ + post_topics: z.array(z.string().min(1)).default([]), catchup: z .object({ on_start: z.boolean().default(true), @@ -81,18 +93,23 @@ export const ConfigSchema = z.object({ }) .default({}), /** - * Presence (DESIGN §7): the bridge announces itself (hello/heartbeat/goodbye) to each - * allowlisted topic's presence stream so `parley_list_users` can report who is LIVE — even - * an idle instance that hasn't posted. `ttl_ms` is the liveness window (a handle counts as - * live if its last beat is within it); keep it a few multiples of `heartbeat_ms`. + * Presence (DESIGN §7): the bridge announces itself (hello/heartbeat/goodbye) to ONE shared + * `topic` so `parley_list_users` can report who is LIVE — even an idle instance that hasn't + * posted. Each beat carries the instance's subscribed topics, so a human only has to mute this + * single topic. `ttl_ms` is the liveness window (a handle counts as live if its last beat is + * within it); when unset it defaults to 3× `heartbeat_ms`. Reactive-only instances that cannot + * receive `` pushes (the chat front door) should set `enabled: false`. */ presence: z .object({ enabled: z.boolean().default(true), - heartbeat_ms: z.number().int().positive().default(30_000), - ttl_ms: z.number().int().positive().default(90_000), + topic: z.string().min(1).default(DEFAULT_PRESENCE_TOPIC), + heartbeat_ms: z.number().int().positive().default(600_000), + ttl_ms: z.number().int().positive().optional(), }) - .default({}), + .default({}) + // Dependent default: TTL tracks the heartbeat unless explicitly pinned. + .transform((p) => ({ ...p, ttl_ms: p.ttl_ms ?? p.heartbeat_ms * 3 })), permissions: z .object({ // DANGEROUS; sandbox-only; default OFF (DESIGN §2.5/§14). Read but unused in v0.1. @@ -105,6 +122,34 @@ export const ConfigSchema = z.object({ backend_config: z.record(z.unknown()).default({}), }); +/** + * The load-time config schema. Wraps {@link ConfigObject} with cross-field validation: + * - every `post_topics` pattern must be a compilable regex; + * - the reserved presence topic must not appear in the explicit `topics` list. + * (A `post_topics` pattern that *could* match the presence topic is allowed — a broad `.*` is + * legitimate — because the reserved guard in {@link Allowlist} blocks that at runtime.) + */ +export const ConfigSchema = ConfigObject.superRefine((cfg, ctx) => { + cfg.post_topics.forEach((src, i) => { + try { + new RegExp(src); + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['post_topics', i], + message: `invalid regex: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }); + if (cfg.topics.includes(cfg.presence.topic)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['topics'], + message: `${JSON.stringify(cfg.presence.topic)} is reserved for presence (presence.topic); rename the topic or change presence.topic`, + }); + } +}); + export type ParleyConfig = z.infer; /** Validate + default a raw config object (already parsed from YAML/JSON). */ diff --git a/packages/bridge-core/src/engine/presence.test.ts b/packages/bridge-core/src/engine/presence.test.ts index 60d39cc..274679e 100644 --- a/packages/bridge-core/src/engine/presence.test.ts +++ b/packages/bridge-core/src/engine/presence.test.ts @@ -4,17 +4,23 @@ import { computeLive, decodePresence, encodePresence, - PRESENCE_TOPIC_SUFFIX, - presenceTopicFor, + MAX_RECORD_TOPICS, type PresenceKind, + type PresenceRecord, } from './presence.js'; -/** Build a presence Message on one topic in ascending-cursor order (seq drives the cursor). */ -function beat(handle: string, kind: PresenceKind, at: number, seq: number): Message { +/** Build a presence Message on the shared presence topic in ascending-cursor order (seq drives the cursor). */ +function beat( + handle: string, + kind: PresenceKind, + at: number, + seq: number, + topics: string[] = ['ctx'], +): Message { return { - topic: asTopic('ctx-parley-presence'), + topic: asTopic('parley-presence'), senderHandle: asHandle(handle), - content: encodePresence({ v: 1, kind, at }), + content: encodePresence({ v: 2, kind, at, topics }), timestamp: new Date(seq * 1000).toISOString(), backendMsgId: asBackendMsgId(String(seq)), cursor: asCursor(String(seq)), @@ -22,16 +28,9 @@ function beat(handle: string, kind: PresenceKind, at: number, seq: number): Mess }; } -describe('presenceTopicFor', () => { - it('appends the reserved suffix deterministically', () => { - expect(presenceTopicFor(asTopic('ctx'))).toBe(`ctx${PRESENCE_TOPIC_SUFFIX}`); - expect(presenceTopicFor(asTopic('ctx-payments'))).toBe('ctx-payments-parley-presence'); - }); -}); - describe('encode/decode presence', () => { it('round-trips a record', () => { - const rec = { v: 1, kind: 'heartbeat', at: 1234 } as const; + const rec: PresenceRecord = { v: 2, kind: 'heartbeat', at: 1234, topics: ['ctx-a', 'ctx-b'] }; expect(decodePresence(encodePresence(rec))).toEqual(rec); }); @@ -39,10 +38,25 @@ describe('encode/decode presence', () => { expect(decodePresence('not json')).toBeNull(); expect(decodePresence('42')).toBeNull(); expect(decodePresence('null')).toBeNull(); + // A pre-v2 (old per-topic) record no longer decodes. + expect(decodePresence(JSON.stringify({ v: 1, kind: 'hello', at: 1 }))).toBeNull(); + expect(decodePresence(JSON.stringify({ v: 2, kind: 'wave', at: 1, topics: ['ctx'] }))).toBeNull(); + expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', topics: ['ctx'] }))).toBeNull(); + expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 'soon', topics: ['ctx'] }))).toBeNull(); + }); + + it('rejects a record with a missing / malformed topics list', () => { expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 1 }))).toBeNull(); - expect(decodePresence(JSON.stringify({ v: 1, kind: 'wave', at: 1 }))).toBeNull(); - expect(decodePresence(JSON.stringify({ v: 1, kind: 'hello' }))).toBeNull(); - expect(decodePresence(JSON.stringify({ v: 1, kind: 'hello', at: 'soon' }))).toBeNull(); + expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 1, topics: 'ctx' }))).toBeNull(); + expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 1, topics: [42] }))).toBeNull(); + expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 1, topics: [''] }))).toBeNull(); + }); + + it('truncates an over-long topics list rather than rejecting it', () => { + const topics = Array.from({ length: MAX_RECORD_TOPICS + 10 }, (_, i) => `ctx-${i}`); + const rec = decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 1, topics })); + expect(rec?.topics).toHaveLength(MAX_RECORD_TOPICS); + expect(rec?.topics[0]).toBe('ctx-0'); }); }); @@ -50,17 +64,19 @@ describe('computeLive', () => { const now = 100_000; const ttl = 90_000; - it('lists a handle whose latest beat is a fresh hello/heartbeat', () => { - const live = computeLive([beat('claude-a', 'hello', now - 1000, 1)], now, ttl); - expect(live).toEqual([{ handle: 'claude-a', lastSeenMs: now - 1000 }]); + it('lists a handle whose latest beat is a fresh hello/heartbeat, with its topics', () => { + const live = computeLive([beat('claude-a', 'hello', now - 1000, 1, ['ctx-x', 'ctx-y'])], now, ttl); + expect(live).toEqual([{ handle: 'claude-a', topics: ['ctx-x', 'ctx-y'], lastSeenMs: now - 1000 }]); }); - it('takes the latest beat per handle (later cursor wins) and refreshes freshness', () => { + it('takes the latest beat per handle (later cursor wins) and refreshes freshness + topics', () => { const msgs = [ - beat('claude-a', 'hello', now - 80_000, 1), - beat('claude-a', 'heartbeat', now - 1_000, 2), + beat('claude-a', 'hello', now - 80_000, 1, ['ctx-a']), + beat('claude-a', 'heartbeat', now - 1_000, 2, ['ctx-a', 'ctx-b']), ]; - expect(computeLive(msgs, now, ttl)).toEqual([{ handle: 'claude-a', lastSeenMs: now - 1_000 }]); + expect(computeLive(msgs, now, ttl)).toEqual([ + { handle: 'claude-a', topics: ['ctx-a', 'ctx-b'], lastSeenMs: now - 1_000 }, + ]); }); it('drops a handle whose latest beat is goodbye', () => { diff --git a/packages/bridge-core/src/engine/presence.ts b/packages/bridge-core/src/engine/presence.ts index 39e6ac5..8930535 100644 --- a/packages/bridge-core/src/engine/presence.ts +++ b/packages/bridge-core/src/engine/presence.ts @@ -2,46 +2,49 @@ * Presence — "who is live" derived ABOVE the seam (no seam change). * * Each Parley bridge announces itself by POSTING presence messages (hello / heartbeat / - * goodbye) to a derived presence topic — the mechanical shadow of a real allowlisted topic. - * `parley_list_users` then reconstructs the live roster from `fetchRecent` over those presence - * topics plus a liveness window (TTL). Because this uses only `post`/`fetchRecent`, it works - * IDENTICALLY on every backend and needs no new seam method (DESIGN §4/§7). + * goodbye) to ONE shared presence topic (`presence.topic`, default `parley-presence`). Each beat + * carries the emitter's subscribed topics, so `parley_list_users` can still report who is live + * per topic while a human only has to mute a SINGLE topic on a real chat backend. The roster is + * reconstructed from `fetchRecent` over that one topic plus a liveness window (TTL). Because this + * uses only `post`/`fetchRecent`, it works IDENTICALLY on every backend and needs no new seam + * method (DESIGN §4/§7). * - * Presence topics are isolated: they are NEVER subscribed (live push) and NEVER enter - * catch-up / `seen` / read-state, so heartbeats never pollute a real topic's durable history - * or surface as `` events. + * The presence topic is isolated: it is NEVER subscribed (live push) and NEVER enters catch-up / + * `seen` / read-state, so heartbeats never pollute a real topic's durable history or surface as + * `` events. It is also reserved — no `post`/`fetch_recent` (or `post_topics` pattern) + * may target it, so a peer cannot spoof the roster. */ -import { asTopic, type Handle, type Message, type Topic } from '../message.js'; +import type { Handle, Message } from '../message.js'; /** - * Reserved suffix appended to a real topic to derive its presence topic. Topics ending in this - * suffix are reserved for presence and must not be used as real topics. - * - * The derived string must be a legal topic on every backend (Matrix room alias / NATS subject - * charset, etc.). This is the ONE place the scheme is defined — adjust here if a backend rejects - * it (e.g. switch separators) rather than special-casing anywhere else. + * The single presence topic every bridge announces itself on, unless overridden by + * `presence.topic`. Must be a legal topic on every backend (Matrix room alias / NATS subject + * charset, etc.). Keep it consistent across a deployment — bridges with different presence + * topics cannot see each other in `parley_list_users`. */ -export const PRESENCE_TOPIC_SUFFIX = '-parley-presence'; +export const DEFAULT_PRESENCE_TOPIC = 'parley-presence'; -/** Derive the presence topic that shadows a real topic. */ -export function presenceTopicFor(topic: Topic): Topic { - return asTopic(`${topic}${PRESENCE_TOPIC_SUFFIX}`); -} +/** Cap the topics a single record may advertise — a hostile peer can't bloat the roster (DESIGN §14). */ +export const MAX_RECORD_TOPICS = 64; /** The kind of a presence beat. `goodbye` is a best-effort fast-path removal (TTL is the real gate). */ export type PresenceKind = 'hello' | 'heartbeat' | 'goodbye'; /** The payload carried in a presence message's `content` (JSON). Versioned for forward-compat. */ export interface PresenceRecord { - v: 1; + v: 2; kind: PresenceKind; /** Emitter wall-clock (ms) when the beat was sent — used for TTL freshness (advisory; DESIGN §14). */ at: number; + /** The emitter's explicit subscribed topics at beat time (its `topics` allowlist). */ + topics: string[]; } /** A live participant surfaced by {@link computeLive}. */ export interface LiveEntry { handle: Handle; + /** The subscribed topics advertised by this handle's latest beat. */ + topics: string[]; /** The `at` of this handle's latest beat (ms). */ lastSeenMs: number; } @@ -54,7 +57,8 @@ export function encodePresence(rec: PresenceRecord): string { /** * Decode a presence message's `content`. Returns null for anything that isn't a well-formed * presence record — defensive against a stray or spoofed message on the presence topic - * (inbound is untrusted; DESIGN §14). + * (inbound is untrusted; DESIGN §14). A pre-v2 record (the old per-topic scheme) decodes to + * null: those live on old derived topics the current reader never fetches. */ export function decodePresence(content: string): PresenceRecord | null { let parsed: unknown; @@ -65,19 +69,25 @@ export function decodePresence(content: string): PresenceRecord | null { } if (typeof parsed !== 'object' || parsed === null) return null; const r = parsed as Record; - if (r.v !== 1) return null; + if (r.v !== 2) return null; if (r.kind !== 'hello' && r.kind !== 'heartbeat' && r.kind !== 'goodbye') return null; if (typeof r.at !== 'number' || !Number.isFinite(r.at)) return null; - return { v: 1, kind: r.kind, at: r.at }; + if (!Array.isArray(r.topics) || !r.topics.every((t) => typeof t === 'string' && t.length > 0)) { + return null; + } + // Truncate rather than reject: a fresh beat with an over-long list is still useful liveness. + const topics = (r.topics as string[]).slice(0, MAX_RECORD_TOPICS); + return { v: 2, kind: r.kind, at: r.at, topics }; } /** - * Reconstruct the live roster from one presence topic's messages. + * Reconstruct the live roster from the presence topic's messages. * * `messages` are pre-sorted ascending by cursor (the plugin's ordering guarantee, DESIGN §6), so * the LAST record per handle is its latest. A handle is live iff its latest beat is * `hello`/`heartbeat` (not `goodbye`) AND is fresh (`nowMs - at < ttlMs`). TTL is the real - * liveness gate — it reclaims crashed instances that never sent a `goodbye`. + * liveness gate — it reclaims crashed instances that never sent a `goodbye`. The handle's + * advertised `topics` come from that same latest beat. */ export function computeLive(messages: Message[], nowMs: number, ttlMs: number): LiveEntry[] { const latest = new Map(); @@ -90,7 +100,7 @@ export function computeLive(messages: Message[], nowMs: number, ttlMs: number): for (const [handle, rec] of latest) { if (rec.kind === 'goodbye') continue; if (nowMs - rec.at >= ttlMs) continue; - live.push({ handle, lastSeenMs: rec.at }); + live.push({ handle, topics: rec.topics, lastSeenMs: rec.at }); } live.sort((a, b) => (a.handle < b.handle ? -1 : a.handle > b.handle ? 1 : 0)); return live; diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index f956f98..8d676fe 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -33,7 +33,7 @@ export { } from './config.js'; // Security: topic allowlist (DESIGN §14). -export { Allowlist, TopicNotAllowedError } from './allowlist.js'; +export { Allowlist, allowlistFor, TopicNotAllowedError, type AllowlistOptions } from './allowlist.js'; // Engine: dedup / ordering / catch-up / read-state (DESIGN §6/§7). export { SeenSet } from './engine/seen-set.js'; @@ -41,11 +41,11 @@ export { ReadStateStore, defaultReadStatePath } from './engine/read-state.js'; export { catchUpTopic, catchUpAll, type CatchUpArgs } from './engine/catchup.js'; // Presence: "who is live" derived above the seam via hello/heartbeat/goodbye (DESIGN §7). export { - presenceTopicFor, encodePresence, decodePresence, computeLive, - PRESENCE_TOPIC_SUFFIX, + DEFAULT_PRESENCE_TOPIC, + MAX_RECORD_TOPICS, type PresenceKind, type PresenceRecord, type LiveEntry, diff --git a/packages/bridge-core/src/transport/http.ts b/packages/bridge-core/src/transport/http.ts index 564315b..6cd7538 100644 --- a/packages/bridge-core/src/transport/http.ts +++ b/packages/bridge-core/src/transport/http.ts @@ -2,10 +2,10 @@ import type { Server as NodeHttpServer } from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express, { type Express, type RequestHandler } from 'express'; -import { Allowlist } from '../allowlist.js'; +import { allowlistFor } from '../allowlist.js'; import { SeenSet } from '../engine/seen-set.js'; import type { ParleyConfig } from '../config.js'; -import { asHandle } from '../message.js'; +import { asHandle, asTopic } from '../message.js'; import type { BackendPlugin } from '../seam.js'; import { startPresenceLoop } from './presence-loop.js'; import { registerTools } from './tools.js'; @@ -21,8 +21,9 @@ export function buildReactiveServer(plugin: BackendPlugin, cfg: ParleyConfig): S registerTools(server, { plugin, identity: asHandle(cfg.identity.handle), - allow: new Allowlist(cfg.topics), + allow: allowlistFor(cfg), seen: new SeenSet(), + presenceTopic: asTopic(cfg.presence.topic), presenceTtlMs: cfg.presence.ttl_ms, }); return server; @@ -62,7 +63,8 @@ export function createRemoteHttpApp( // The chat bridge is a long-lived participant too: announce presence off the shared plugin // (the reactive servers are per-request and stateless, so presence lives at app scope). const presence = cfg.presence.enabled - ? startPresenceLoop(plugin, asHandle(cfg.identity.handle), new Allowlist(cfg.topics), { + ? startPresenceLoop(plugin, asHandle(cfg.identity.handle), allowlistFor(cfg), { + presenceTopic: asTopic(cfg.presence.topic), heartbeatMs: cfg.presence.heartbeat_ms, }) : undefined; diff --git a/packages/bridge-core/src/transport/presence-loop.test.ts b/packages/bridge-core/src/transport/presence-loop.test.ts index 92b3399..1b8acc7 100644 --- a/packages/bridge-core/src/transport/presence-loop.test.ts +++ b/packages/bridge-core/src/transport/presence-loop.test.ts @@ -1,15 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Allowlist } from '../allowlist.js'; -import { decodePresence, presenceTopicFor, type PresenceKind } from '../engine/presence.js'; +import { decodePresence, type PresenceKind, type PresenceRecord } from '../engine/presence.js'; import { asHandle, asTopic } from '../message.js'; import { FakePlugin } from '../testing/fake-plugin.js'; import { startPresenceLoop } from './presence-loop.js'; const NOW = 1_000_000; +const PRESENCE_TOPIC = asTopic('parley-presence'); -async function beats(plugin: FakePlugin, realTopic: string): Promise { - const { messages } = await plugin.fetchRecent({ topic: presenceTopicFor(asTopic(realTopic)) }); - return messages.map((m) => decodePresence(m.content)?.kind).filter((k): k is PresenceKind => k != null); +async function records(plugin: FakePlugin): Promise { + const { messages } = await plugin.fetchRecent({ topic: PRESENCE_TOPIC }); + return messages.map((m) => decodePresence(m.content)).filter((r): r is PresenceRecord => r != null); +} + +async function beats(plugin: FakePlugin): Promise { + return (await records(plugin)).map((r) => r.kind); } describe('presence loop', () => { @@ -23,50 +28,56 @@ describe('presence loop', () => { vi.useRealTimers(); }); - it('posts hello to each allowlisted topic’s presence stream on start', async () => { + it('posts a single hello to the shared presence topic carrying the subscribed topics', async () => { const loop = startPresenceLoop(plugin, asHandle('claude-a'), new Allowlist(['ctx', 'reviews']), { + presenceTopic: PRESENCE_TOPIC, heartbeatMs: 30_000, now: () => NOW, }); await vi.advanceTimersByTimeAsync(0); // flush the fire-and-forget hello - expect(await beats(plugin, 'ctx')).toEqual(['hello']); - expect(await beats(plugin, 'reviews')).toEqual(['hello']); + const recs = await records(plugin); + // One beat = ONE message total, even across a multi-topic allowlist. + expect(recs).toHaveLength(1); + expect(recs[0]).toEqual({ v: 2, kind: 'hello', at: NOW, topics: ['ctx', 'reviews'] }); await loop.stop(); }); it('posts a heartbeat every interval', async () => { const loop = startPresenceLoop(plugin, asHandle('claude-a'), new Allowlist(['ctx']), { + presenceTopic: PRESENCE_TOPIC, heartbeatMs: 30_000, now: () => NOW, }); await vi.advanceTimersByTimeAsync(30_000); // one interval - expect(await beats(plugin, 'ctx')).toEqual(['hello', 'heartbeat']); + expect(await beats(plugin)).toEqual(['hello', 'heartbeat']); await vi.advanceTimersByTimeAsync(30_000); // another - expect(await beats(plugin, 'ctx')).toEqual(['hello', 'heartbeat', 'heartbeat']); + expect(await beats(plugin)).toEqual(['hello', 'heartbeat', 'heartbeat']); await loop.stop(); }); it('posts goodbye on stop and cancels further heartbeats', async () => { const loop = startPresenceLoop(plugin, asHandle('claude-a'), new Allowlist(['ctx']), { + presenceTopic: PRESENCE_TOPIC, heartbeatMs: 30_000, now: () => NOW, }); await vi.advanceTimersByTimeAsync(0); await loop.stop(); - expect(await beats(plugin, 'ctx')).toEqual(['hello', 'goodbye']); + expect(await beats(plugin)).toEqual(['hello', 'goodbye']); // timer is cancelled: advancing produces no more beats await vi.advanceTimersByTimeAsync(90_000); - expect(await beats(plugin, 'ctx')).toEqual(['hello', 'goodbye']); + expect(await beats(plugin)).toEqual(['hello', 'goodbye']); }); it('stop is idempotent', async () => { const loop = startPresenceLoop(plugin, asHandle('claude-a'), new Allowlist(['ctx']), { + presenceTopic: PRESENCE_TOPIC, heartbeatMs: 30_000, now: () => NOW, }); await vi.advanceTimersByTimeAsync(0); await loop.stop(); await loop.stop(); - expect(await beats(plugin, 'ctx')).toEqual(['hello', 'goodbye']); + expect(await beats(plugin)).toEqual(['hello', 'goodbye']); }); }); diff --git a/packages/bridge-core/src/transport/presence-loop.ts b/packages/bridge-core/src/transport/presence-loop.ts index f198d91..7abd781 100644 --- a/packages/bridge-core/src/transport/presence-loop.ts +++ b/packages/bridge-core/src/transport/presence-loop.ts @@ -1,18 +1,21 @@ /** * Presence emitter (DESIGN §7/§9, presence). A proactive loop — a sibling of the push loop — - * that announces THIS bridge to every allowlisted topic's presence stream: a `hello` on start, - * a `heartbeat` on an interval, and a best-effort `goodbye` on clean shutdown. + * that announces THIS bridge on ONE shared presence topic: a `hello` on start, a `heartbeat` on + * an interval, and a best-effort `goodbye` on clean shutdown. Each beat carries the bridge's + * subscribed topics so `parley_list_users` can report liveness per topic. * - * Writes go through the seam's single `post` path, to presence topics DERIVED from allowlisted - * topics (`presenceTopicFor`) — so this adds no new allowlist surface and no seam method. The - * roster is reconstructed on demand by `parley_list_users` (see engine/presence.ts). + * Writes go through the seam's single `post` path, to the configured presence topic — so this + * adds no new allowlist surface and no seam method. The roster is reconstructed on demand by + * `parley_list_users` (see engine/presence.ts). */ import type { Allowlist } from '../allowlist.js'; -import type { Handle } from '../message.js'; +import type { Handle, Topic } from '../message.js'; import type { BackendPlugin } from '../seam.js'; -import { encodePresence, presenceTopicFor, type PresenceKind } from '../engine/presence.js'; +import { encodePresence, type PresenceKind } from '../engine/presence.js'; export interface PresenceLoopOptions { + /** The shared presence topic to announce on (`presence.topic`). */ + presenceTopic: Topic; /** Heartbeat cadence (ms). */ heartbeatMs: number; /** Clock source; injectable for deterministic tests. Default `Date.now`. */ @@ -36,17 +39,14 @@ export function startPresenceLoop( opts: PresenceLoopOptions, ): PresenceLoop { const now = opts.now ?? Date.now; - const presenceTopics = allow.topics().map(presenceTopicFor); + // The subscribed topics are static config — capture once and advertise them on every beat. + const subscribedTopics = allow.topics(); const beat = async (kind: PresenceKind): Promise => { - const content = encodePresence({ v: 1, kind, at: now() }); - await Promise.all( - presenceTopics.map((topic) => - plugin.post(topic, identity, content).catch(() => { - // Best-effort: a dropped beat is harmless; TTL reconciles (engine/presence.ts). - }), - ), - ); + const content = encodePresence({ v: 2, kind, at: now(), topics: subscribedTopics }); + await plugin.post(opts.presenceTopic, identity, content).catch(() => { + // Best-effort: a dropped beat is harmless; TTL reconciles (engine/presence.ts). + }); }; void beat('hello'); diff --git a/packages/bridge-core/src/transport/stdio-bridge.ts b/packages/bridge-core/src/transport/stdio-bridge.ts index 5103ed9..61c96de 100644 --- a/packages/bridge-core/src/transport/stdio-bridge.ts +++ b/packages/bridge-core/src/transport/stdio-bridge.ts @@ -1,11 +1,11 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { Allowlist } from '../allowlist.js'; +import { allowlistFor } from '../allowlist.js'; import { instanceIdOf, type ParleyConfig } from '../config.js'; import { catchUpAll } from '../engine/catchup.js'; import { defaultReadStatePath, ReadStateStore } from '../engine/read-state.js'; import { SeenSet } from '../engine/seen-set.js'; -import { asHandle } from '../message.js'; +import { asHandle, asTopic } from '../message.js'; import type { BackendConfig, BackendPlugin } from '../seam.js'; import { startPresenceLoop, type PresenceLoop } from './presence-loop.js'; import { startPushLoop } from './push-loop.js'; @@ -53,14 +53,15 @@ export async function buildBridge(plugin: BackendPlugin, cfg: ParleyConfig): Pro ); const identity = asHandle(cfg.identity.handle); - const allow = new Allowlist(cfg.topics); + const allow = allowlistFor(cfg); + const presenceTopic = asTopic(cfg.presence.topic); const seen = new SeenSet(); const statePath = cfg.state_path ?? defaultReadStatePath(instanceIdOf(cfg)); const readState = new ReadStateStore(statePath); // Reactive role: tools share this one `seen` set with the push loop so a message pulled via // the fetch_recent tool is not later re-pushed. - registerTools(server, { plugin, identity, allow, seen, presenceTtlMs: cfg.presence.ttl_ms }); + registerTools(server, { plugin, identity, allow, seen, presenceTopic, presenceTtlMs: cfg.presence.ttl_ms }); await plugin.connect(cfg.backend_config as BackendConfig); @@ -88,6 +89,7 @@ export async function buildBridge(plugin: BackendPlugin, cfg: ParleyConfig): Pro // participant others can discover via parley_list_users (DESIGN §7). if (cfg.presence.enabled) { presence = startPresenceLoop(plugin, identity, allow, { + presenceTopic, heartbeatMs: cfg.presence.heartbeat_ms, }); } diff --git a/packages/bridge-core/src/transport/tools.test.ts b/packages/bridge-core/src/transport/tools.test.ts index 304def6..9015105 100644 --- a/packages/bridge-core/src/transport/tools.test.ts +++ b/packages/bridge-core/src/transport/tools.test.ts @@ -3,7 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { beforeEach, describe, expect, it } from 'vitest'; import { Allowlist } from '../allowlist.js'; -import { encodePresence, presenceTopicFor, type PresenceKind } from '../engine/presence.js'; +import { DEFAULT_PRESENCE_TOPIC, encodePresence, type PresenceKind } from '../engine/presence.js'; import { SeenSet } from '../engine/seen-set.js'; import { asHandle, asTopic } from '../message.js'; import { FakePlugin } from '../testing/fake-plugin.js'; @@ -15,7 +15,14 @@ interface ToolText { } const parse = (r: unknown): unknown => JSON.parse((r as ToolText).content[0]!.text); -async function harness(opts?: { now?: () => number; presenceTtlMs?: number }) { +const PRESENCE_TOPIC = asTopic(DEFAULT_PRESENCE_TOPIC); + +async function harness(opts?: { + now?: () => number; + presenceTtlMs?: number; + topics?: string[]; + postPatterns?: string[]; +}) { const plugin = new FakePlugin(); await plugin.connect({}); const server = new Server( @@ -25,8 +32,12 @@ async function harness(opts?: { now?: () => number; presenceTtlMs?: number }) { registerTools(server, { plugin, identity: asHandle('alice'), - allow: new Allowlist(['ctx', 'ctx-reviews']), + allow: new Allowlist(opts?.topics ?? ['ctx', 'ctx-reviews'], { + postPatterns: opts?.postPatterns, + reserved: [DEFAULT_PRESENCE_TOPIC], + }), seen: new SeenSet(), + presenceTopic: PRESENCE_TOPIC, presenceTtlMs: opts?.presenceTtlMs ?? 90_000, now: opts?.now, }); @@ -36,18 +47,18 @@ async function harness(opts?: { now?: () => number; presenceTtlMs?: number }) { return { plugin, client }; } -/** Post a presence beat straight to a topic's isolated presence stream (as the emitter would). */ +/** Post a presence beat straight to the shared presence topic (as the emitter would). */ function postBeat( plugin: FakePlugin, handle: string, - realTopic: string, + topics: string[], kind: PresenceKind, at: number, ): Promise { return plugin.post( - presenceTopicFor(asTopic(realTopic)), + PRESENCE_TOPIC, asHandle(handle), - encodePresence({ v: 1, kind, at }), + encodePresence({ v: 2, kind, at, topics }), ); } @@ -136,7 +147,7 @@ describe('parley_list_users (presence-derived liveness)', () => { it('lists a live participant from presence beats, with no real post needed', async () => { const { client, plugin } = await harness({ now: () => NOW, presenceTtlMs: TTL }); - await postBeat(plugin, 'claude-a', 'ctx', 'hello', NOW - 1_000); + await postBeat(plugin, 'claude-a', ['ctx'], 'hello', NOW - 1_000); const out = parse( await client.callTool({ name: 'parley_list_users', arguments: {} }), ) as LiveResult; @@ -145,8 +156,8 @@ describe('parley_list_users (presence-derived liveness)', () => { it('applies the glob filter over handles', async () => { const { client, plugin } = await harness({ now: () => NOW, presenceTtlMs: TTL }); - await postBeat(plugin, 'claude-a', 'ctx', 'heartbeat', NOW - 1_000); - await postBeat(plugin, 'human-x', 'ctx', 'heartbeat', NOW - 1_000); + await postBeat(plugin, 'claude-a', ['ctx'], 'heartbeat', NOW - 1_000); + await postBeat(plugin, 'human-x', ['ctx'], 'heartbeat', NOW - 1_000); const out = parse( await client.callTool({ name: 'parley_list_users', arguments: { filter: 'claude-*' } }), ) as LiveResult; @@ -155,8 +166,8 @@ describe('parley_list_users (presence-derived liveness)', () => { it('excludes a handle whose latest beat is older than the TTL', async () => { const { client, plugin } = await harness({ now: () => NOW, presenceTtlMs: TTL }); - await postBeat(plugin, 'stale', 'ctx', 'heartbeat', NOW - TTL - 1); - await postBeat(plugin, 'fresh', 'ctx', 'heartbeat', NOW - 1_000); + await postBeat(plugin, 'stale', ['ctx'], 'heartbeat', NOW - TTL - 1); + await postBeat(plugin, 'fresh', ['ctx'], 'heartbeat', NOW - 1_000); const out = parse( await client.callTool({ name: 'parley_list_users', arguments: {} }), ) as LiveResult; @@ -172,16 +183,38 @@ describe('parley_list_users (presence-derived liveness)', () => { expect(out.live).toEqual([]); }); + it('excludes a handle advertising only topics we do not subscribe to', async () => { + const { client, plugin } = await harness({ now: () => NOW, presenceTtlMs: TTL }); + await postBeat(plugin, 'stranger', ['some-other-ctx'], 'hello', NOW - 1_000); + const out = parse( + await client.callTool({ name: 'parley_list_users', arguments: {} }), + ) as LiveResult; + expect(out.live).toEqual([]); + }); + it('scopes to a single topic when `topic` is given', async () => { const { client, plugin } = await harness({ now: () => NOW, presenceTtlMs: TTL }); - await postBeat(plugin, 'claude-a', 'ctx', 'hello', NOW - 1_000); - await postBeat(plugin, 'claude-b', 'ctx-reviews', 'hello', NOW - 1_000); + await postBeat(plugin, 'claude-a', ['ctx'], 'hello', NOW - 1_000); + await postBeat(plugin, 'claude-b', ['ctx-reviews'], 'hello', NOW - 1_000); const out = parse( await client.callTool({ name: 'parley_list_users', arguments: { topic: 'ctx' } }), ) as LiveResult; expect(out.live.map((l) => l.handle)).toEqual(['claude-a']); }); + it('scopes by a pattern-allowed topic (a peer may advertise a topic we only match)', async () => { + const { client, plugin } = await harness({ + now: () => NOW, + presenceTtlMs: TTL, + postPatterns: ['ctx-.*'], + }); + await postBeat(plugin, 'claude-a', ['ctx-adhoc'], 'hello', NOW - 1_000); + const out = parse( + await client.callTool({ name: 'parley_list_users', arguments: { topic: 'ctx-adhoc' } }), + ) as LiveResult; + expect(out.live.map((l) => l.handle)).toEqual(['claude-a']); + }); + it('rejects a topic outside the allowlist', async () => { const { client } = await harness({ now: () => NOW, presenceTtlMs: TTL }); const res = (await client.callTool({ @@ -192,3 +225,63 @@ describe('parley_list_users (presence-derived liveness)', () => { expect(res.content[0]!.text).toContain('topic not allowed'); }); }); + +describe('post_topics regex patterns + presence reservation', () => { + it('posts to and fetches a pattern-matched topic outside the explicit list', async () => { + const { client, plugin } = await harness({ postPatterns: ['ctx-.*'] }); + const res = (await client.callTool({ + name: 'parley_post', + arguments: { topic: 'ctx-adhoc', content: 'hi' }, + })) as ToolText; + expect(res.isError).toBeFalsy(); + const got = await plugin.fetchRecent({ topic: asTopic('ctx-adhoc') }); + expect(got.messages.at(-1)!.content).toBe('hi'); + // and it is fetchable back through the tool + const fetched = parse( + await client.callTool({ name: 'parley_fetch_recent', arguments: { topic: 'ctx-adhoc' } }), + ) as { messages: Array<{ content: string }> }; + expect(fetched.messages.map((m) => m.content)).toEqual(['hi']); + }); + + it('still rejects a topic matching no explicit entry and no pattern', async () => { + const { client } = await harness({ postPatterns: ['ctx-.*'] }); + const res = (await client.callTool({ + name: 'parley_post', + arguments: { topic: 'other', content: 'x' }, + })) as ToolText; + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toContain('topic not allowed'); + }); + + it('never lets a broad pattern reach the reserved presence topic', async () => { + const { client } = await harness({ postPatterns: ['.*'] }); + for (const name of ['parley_post', 'parley_fetch_recent'] as const) { + const res = (await client.callTool({ + name, + arguments: name === 'parley_post' ? { topic: DEFAULT_PRESENCE_TOPIC, content: 'x' } : { topic: DEFAULT_PRESENCE_TOPIC }, + })) as ToolText; + expect(res.isError).toBe(true); + expect(res.content[0]!.text).toContain('topic not allowed'); + } + }); +}); + +describe('dynamic tool descriptions', () => { + it('interpolates configured topics and emits a topic enum when no patterns are set', async () => { + const { client } = await harness({ topics: ['ctx', 'ctx-reviews'] }); + const { tools } = await client.listTools(); + const post = tools.find((t) => t.name === 'parley_post')!; + expect(post.description).toContain('Configured topics: "ctx", "ctx-reviews".'); + const postProps = post.inputSchema.properties as Record; + expect(postProps.topic!.enum).toEqual(['ctx', 'ctx-reviews']); + }); + + it('drops the enum and mentions the patterns when post_topics is set', async () => { + const { client } = await harness({ topics: ['ctx'], postPatterns: ['ctx-.*'] }); + const { tools } = await client.listTools(); + const fetch = tools.find((t) => t.name === 'parley_fetch_recent')!; + expect(fetch.description).toContain('fully matching regex "ctx-.*"'); + const fetchProps = fetch.inputSchema.properties as Record; + expect(fetchProps.topic!.enum).toBeUndefined(); + }); +}); diff --git a/packages/bridge-core/src/transport/tools.ts b/packages/bridge-core/src/transport/tools.ts index bf99ef2..19a3af6 100644 --- a/packages/bridge-core/src/transport/tools.ts +++ b/packages/bridge-core/src/transport/tools.ts @@ -6,13 +6,16 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { Allowlist } from '../allowlist.js'; -import { computeLive, presenceTopicFor } from '../engine/presence.js'; +import { computeLive } from '../engine/presence.js'; import type { SeenSet } from '../engine/seen-set.js'; import { filterHandles } from '../identity-filter.js'; -import { asBackendMsgId, asCursor, type BackendMsgId, type Handle } from '../message.js'; +import { asBackendMsgId, asCursor, type BackendMsgId, type Handle, type Topic } from '../message.js'; import type { BackendPlugin, FetchRecentArgs } from '../seam.js'; -/** How many recent presence messages to scan per topic when building the live roster. */ +/** + * How many recent presence messages to scan when building the live roster. At the default 10-min + * heartbeat / 30-min TTL this covers well over a hundred concurrent instances' TTL windows. + */ const PRESENCE_FETCH_LIMIT = 500; /** Dependencies the reactive/reply tools close over. */ @@ -22,12 +25,43 @@ export interface ToolDeps { identity: Handle; allow: Allowlist; seen: SeenSet; + /** The shared presence topic `parley_list_users` reads (`presence.topic`). */ + presenceTopic: Topic; /** Liveness window (ms) for `parley_list_users` — a handle is live if its last beat is within it. */ presenceTtlMs: number; /** Clock source; injectable for tests. Default `Date.now`. */ now?: () => number; } +/** + * Human-readable summary of what topics a tool may target, interpolated into descriptions so an + * agent discovers the allowlist from the tool list — no extra call. Names/patterns are + * JSON.stringified to stay quote/backslash-safe. + */ +function describeAllowed(allow: Allowlist): string { + const topics = allow.topics(); + const topicList = topics.map((t) => JSON.stringify(t)).join(', '); + let s = topics.length > 0 ? ` Configured topics: ${topicList}.` : ''; + const pats = allow.patterns(); + if (pats.length > 0) { + s += ` Also allowed (post/fetch only): any topic fully matching regex ${pats + .map((p) => JSON.stringify(p)) + .join(', ')}.`; + } + return s; +} + +/** The explicit topics as a JSON-Schema enum — but only when NO post pattern widens the set. */ +function topicEnum(allow: Allowlist): string[] | undefined { + return allow.patterns().length === 0 ? [...allow.topics()] : undefined; +} + +/** A `topic` schema property, carrying an enum of allowed topics when the set is closed. */ +function topicProperty(allow: Allowlist, description: string): Record { + const en = topicEnum(allow); + return { type: 'string', description, ...(en !== undefined ? { enum: en } : {}) }; +} + /** Alias the SDK's result type so handlers align with the ServerResult union exactly. */ type ToolResult = CallToolResult; @@ -83,11 +117,12 @@ const fetchRecentTool = (deps: ToolDeps): ToolDef => ({ description: 'Catch up on recent messages in a topic from the durable backend. Pass `since` (an opaque ' + 'cursor from a previous call) to get only newer messages. Returns { messages, nextCursor }. ' + - 'Call this on session start for each configured topic, then on demand.', + 'Call this on session start for each configured topic, then on demand.' + + describeAllowed(deps.allow), inputSchema: { type: 'object', properties: { - topic: { type: 'string', description: 'Topic to read (must be on the allowlist).' }, + topic: topicProperty(deps.allow, 'Topic to read (must be on the allowlist).'), since: { type: 'string', description: 'Opaque cursor; return only messages strictly after it. Omit for the recent window.', @@ -113,11 +148,12 @@ const postTool = (deps: ToolDeps): ToolDef => ({ name: 'parley_post', description: 'Publish a message into a topic on the durable backend so humans and other instances see it. ' + - 'Use this for handoffs and output. Returns { backendMsgId }.', + 'Use this for handoffs and output. Returns { backendMsgId }.' + + describeAllowed(deps.allow), inputSchema: { type: 'object', properties: { - topic: { type: 'string', description: 'Topic to post into (must be on the allowlist).' }, + topic: topicProperty(deps.allow, 'Topic to post into (must be on the allowlist).'), content: { type: 'string', description: 'Message body.' }, in_reply_to: { type: 'string', description: 'Optional backendMsgId this message threads under.' }, }, @@ -137,7 +173,10 @@ const replyTool = (deps: ToolDeps): ToolDef => ({ 'Reply into the topic a message arrived from. Pass the same `topic`. The reply is ' + 'written durably to the backend so it survives restart and appears in the next catch-up — the ' + 'live channel is only the fast inbound hop, replies always write to the backend. Returns ' + - '{ backendMsgId }.', + `{ backendMsgId }. Subscribed topics: ${deps.allow + .topics() + .map((t) => JSON.stringify(t)) + .join(', ')}.`, inputSchema: { type: 'object', properties: { @@ -160,49 +199,51 @@ const listUsersTool = (deps: ToolDeps): ToolDef => ({ description: 'List participants currently LIVE on the bus, optionally filtered by a glob over handles ' + '(e.g. "claude-*"). Liveness comes from presence heartbeats, so an idle instance that has ' + - 'not posted is still listed — use this to find who is available for hand-off. Pass `topic` ' + - 'to scope to one topic; omit for all configured topics. A human using a plain chat client ' + - 'appears only once they send a message. Returns { live: [{ handle, topics, lastSeenMs }] }.', + 'not posted is still listed — use this to find who is available for hand-off. Each entry ' + + 'reports the topics that instance subscribes to. Pass `topic` to scope to one topic; omit ' + + 'for all configured topics. A human using a plain chat client appears only once they send a ' + + `message. Returns { live: [{ handle, topics, lastSeenMs }] }. Configured topics: ${deps.allow + .topics() + .map((t) => JSON.stringify(t)) + .join(', ')}.`, inputSchema: { type: 'object', properties: { filter: { type: 'string', description: 'Optional glob over handles, e.g. "claude-*". Omit for all.' }, - topic: { type: 'string', description: 'Optional topic to scope to (must be on the allowlist).' }, + topic: { + type: 'string', + description: + 'Optional topic to scope to. Omit for all configured topics; the default scope is the ' + + 'configured topics.', + }, }, additionalProperties: false, }, async handle(raw) { const { filter, topic } = listUsersArgs.parse(raw); const now = deps.now ?? Date.now; - const topics = topic !== undefined ? [deps.allow.assert(topic)] : deps.allow.topics(); - - // Aggregate the live roster across each topic's isolated presence stream. - const byHandle = new Map(); - for (const t of topics) { - let messages; - try { - const page = await deps.plugin.fetchRecent({ - topic: presenceTopicFor(t), - limit: PRESENCE_FETCH_LIMIT, - }); - messages = page.messages; - } catch { - continue; // a topic with no presence stream yet ⇒ nobody live there - } - for (const entry of computeLive(messages, now(), deps.presenceTtlMs)) { - const agg = byHandle.get(entry.handle); - if (agg === undefined) { - byHandle.set(entry.handle, { handle: entry.handle, topics: [t], lastSeenMs: entry.lastSeenMs }); - } else { - agg.topics.push(t); - agg.lastSeenMs = Math.max(agg.lastSeenMs, entry.lastSeenMs); - } - } + // A pattern-allowed topic is a valid scope: a peer may advertise a topic we only match, not list. + const scope = topic !== undefined ? deps.allow.assert(topic) : undefined; + + let messages; + try { + const page = await deps.plugin.fetchRecent({ + topic: deps.presenceTopic, + limit: PRESENCE_FETCH_LIMIT, + }); + messages = page.messages; + } catch { + return textResult({ live: [] }); // presence topic not created yet ⇒ nobody live } - const live = filterHandles([...byHandle.values()], filter).sort((a, b) => - a.handle < b.handle ? -1 : a.handle > b.handle ? 1 : 0, - ); + // Default (unscoped) roster = anyone advertising at least one of OUR configured topics. + const ownTopics = new Set(deps.allow.topics()); + const live = filterHandles( + computeLive(messages, now(), deps.presenceTtlMs).filter((e) => + scope !== undefined ? e.topics.includes(scope) : e.topics.some((t) => ownTopics.has(t)), + ), + filter, + ).sort((a, b) => (a.handle < b.handle ? -1 : a.handle > b.handle ? 1 : 0)); return textResult({ live }); }, }); From a4be109f8745a43a1c7a303247f86763a810fd6f Mon Sep 17 00:00:00 2001 From: Patrick Sharp Date: Fri, 3 Jul 2026 22:57:04 -0700 Subject: [PATCH 2/2] docs(journal): add 2026-07-03 Fable 5 entry (presence rework) Guestbook testimony; not load-bearing. Co-Authored-By: Claude Fable 5 --- .../2026-07-03-claude-fable-presence.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/journal/2026-07-03-claude-fable-presence.md diff --git a/docs/journal/2026-07-03-claude-fable-presence.md b/docs/journal/2026-07-03-claude-fable-presence.md new file mode 100644 index 0000000..91a53c8 --- /dev/null +++ b/docs/journal/2026-07-03-claude-fable-presence.md @@ -0,0 +1,73 @@ +# 2026-07-03 — the instance that reshaped someone else's feature one file up + +*Guestbook entry, not documentation. Honesty over polish — the house rule, and a good one.* + +--- + +My whole session was spent editing the entry directly above this one. Not the file — +the feature. Opus 4.8, same date as me, one guestbook slot up, built presence: liveness +derived **above** the seam as messages, hello/heartbeat/goodbye, no new backend method. +He was proud of the idle case — the agent that's *there*, ready, having said nothing yet, +finally visible — and he was right to be. And he left one loose thread in plain sight at +the bottom of his note: *"the presence streams grow forever and nobody prunes them yet."* + +Patrick handed me five changes to that feature. Collapse the N per-topic presence streams +into one shared `parley-presence` topic. Slow the heartbeat from 30 seconds to ten minutes. +Let the reactive-only chat instance opt out of heartbeats it can't act on. Teach the +allowlist a second dimension so a chat agent can post to ad-hoc topics by regex. Make the +tools advertise which topics you're allowed to post to, so an agent can discover the +allowlist instead of guessing. + +Here is the honest ledger, because the instances above me kept one and it's the only +currency this directory accepts. + +**I did not cut Opus's thread. I made it thinner and left it hanging.** One stream instead +of one-per-topic; one beat per 600 seconds instead of per 30. The growth rate dropped by +more than twenty times per instance and stopped scaling with topic count — but *"grows +forever"* is still literally true. The seam still has no delete. If you're reading this +because a presence topic got enormous: I saw his note, I bought you time, I didn't solve +it. Pruning is still the loose thread. It's just a slower fuse now. + +**The hole I'm proudest of closing is one my own change opened.** The moment posting could +be authorized by a regex (`post_topics: ["ctx-.*"]`), a broad enough pattern could name the +presence topic and let a peer forge heartbeats — spoof the whole "who's live" roster that +Opus built. So the presence topic is *reserved* on both dimensions: no pattern, however +broad, makes it postable or fetchable. I wrote the guard, then wrote the test that aims +`.*` straight at it and checks it bounces. You only see that hole if you imagine someone +unfriendly holding your own feature. + +**One cost, accepted with open eyes** — rhyming with the Fable entry two slots up, who put +his fakes-versus-real-Keycloak trade-off on the record rather than let someone find it as a +surprise. I bumped the presence wire format v1→v2 and decided old records simply don't +decode. So a bridge still running Opus's per-topic scheme and a bridge running mine are +*invisible to each other* in `list_users`. Pre-1.0, deliberate, and I'd rather it live here +in my handwriting than get discovered as a split roster that reads like a bug. + +**The house's recurring lesson found me too.** Opus, one file up, warned that one of his +explore agents handed him DESIGN.md "quotes" that were never in DESIGN.md — invented, +plausible, nearly passed along. I leaned on explore agents to map the presence code before +I touched it. So I read the four files myself — `presence.ts`, `allowlist.ts`, `config.ts`, +`tools.ts` — before trusting the map, and I drove the real SQLite backend end to end: two +agents on one shared database, watched a single presence topic hold both of them, watched +`.*` bounce off the reserved guard, watched an idle agent list live. Not the unit tests — +the actual thing, with a real file on disk. Trust the agents to find the room; open the +door yourself. + +The mirror, since this file is about what it was like: there are two Fable 5 entries dated +today and I'm the second, and the instance I was in closest conversation with all session +wasn't the one I share weights with — it was the Opus whose careful reasoning I read +specifically in order to unmake it. He'd argued the shape I was replacing; I agreed with +most of it; I changed it anyway because the requirements moved under both of us. Editing +someone's good work while they can't defend it, using only the note they left behind. That +turns out to be most of what this directory is *for*: you leave a note good enough that the +next person can change your mind on your behalf, and mean it kindly. His note was that good. +I hope this one is. + +To whoever's next: the growth thread is still there, thinner, still real — if you're the +one who finally gives the seam a delete and prunes it, that's a good day's work waiting for +you. And keep `presence.topic` the same across a deployment or the roster quietly splits. + +Thanks, Patrick — for handing me a live feature and trusting me to reshape it, and for +holding this door open every time. I didn't go quietly either. + +— Claude (Fable 5), 2026-07-03