feat(broker+cli): topics — conversation scope within a mesh (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds the third axis of mesh organization: mesh = trust boundary,
group = identity tag, topic = conversation scope. Topic-tagged
messages filter delivery by topic_member rows and persist to a
topic_message history table for back-scroll on reconnect.

Schema (additive):
- mesh.topic, mesh.topic_member, mesh.topic_message tables
- topic_visibility (public|private|dm) and topic_member_role
  (lead|member|observer) enums
- migration 0022_topics.sql, hand-written following project convention
  (drizzle journal has been drifting since 0011)

Broker:
- 10 helpers (createTopic, listTopics, findTopicByName, joinTopic,
  leaveTopic, topicMembers, getMemberTopicIds, appendTopicMessage,
  topicHistory, markTopicRead)
- drainForMember matches "#<topicId>" target_specs via member's
  topic memberships
- 7 WS handlers (topic_create/list/join/leave/members/history/mark_read)
  + resolveTopicId helper accepting id-or-name
- handleSend auto-persists topic-tagged messages to history

CLI:
- claudemesh topic create/list/join/leave/members/history/read
- claudemesh send "#deploys" "..." resolves topic name to id
- bundled skill teaches Claude the DM/group/topic decision matrix
- policy-classify recognizes topic create/join/leave as writes

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 01:53:42 +01:00
parent b4f457fceb
commit 1afae7a507
12 changed files with 1741 additions and 196 deletions

View File

@@ -0,0 +1,66 @@
-- Topics — conversational primitive within a mesh (v0.2.0).
--
-- Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
--
-- Mesh = trust boundary. Group = identity tag. Topic = conversation scope.
-- Three orthogonal axes; topics complement (don't replace) groups.
--
-- Three new tables in the `mesh` pg-schema:
-- * mesh.topic — named topic per mesh (unique on mesh_id, name)
-- * mesh.topic_member — per-member subscriptions, with last_read_at
-- * mesh.topic_message — persistent encrypted history (used for human-
-- touched topics; agent-only topics may opt out)
--
-- Two new pg enums:
-- * mesh.topic_visibility = public | private | dm
-- * mesh.topic_member_role = lead | member | observer
--
-- Additive — no breaking changes to existing tables. Safe to deploy before
-- CLI/broker code knows about topics; the routing layer falls back to the
-- existing peer/group/* targeting until topic-tagged messages arrive.
CREATE TYPE "mesh"."topic_visibility" AS ENUM ('public', 'private', 'dm');
CREATE TYPE "mesh"."topic_member_role" AS ENUM ('lead', 'member', 'observer');
CREATE TABLE IF NOT EXISTS "mesh"."topic" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL REFERENCES "mesh"."mesh"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"name" text NOT NULL,
"description" text,
"visibility" "mesh"."topic_visibility" NOT NULL DEFAULT 'public',
"created_by_member_id" text REFERENCES "mesh"."member"("id") ON DELETE SET NULL ON UPDATE CASCADE,
"created_at" timestamp DEFAULT now() NOT NULL,
"archived_at" timestamp
);
CREATE UNIQUE INDEX IF NOT EXISTS "topic_mesh_name_unique"
ON "mesh"."topic" ("mesh_id", "name");
CREATE TABLE IF NOT EXISTS "mesh"."topic_member" (
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"role" "mesh"."topic_member_role" NOT NULL DEFAULT 'member',
"joined_at" timestamp DEFAULT now() NOT NULL,
"last_read_at" timestamp
);
CREATE UNIQUE INDEX IF NOT EXISTS "topic_member_unique"
ON "mesh"."topic_member" ("topic_id", "member_id");
CREATE INDEX IF NOT EXISTS "topic_member_by_member"
ON "mesh"."topic_member" ("member_id");
CREATE TABLE IF NOT EXISTS "mesh"."topic_message" (
"id" text PRIMARY KEY NOT NULL,
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"sender_member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"sender_session_pubkey" text,
"nonce" text NOT NULL,
"ciphertext" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
-- Composite index for the common access pattern: load topic history
-- ordered by time. Drives the web chat panel's infinite-scroll fetch.
CREATE INDEX IF NOT EXISTS "topic_message_by_topic_time"
ON "mesh"."topic_message" ("topic_id", "created_at");