constitution · epochs · watch · epoch 3

comparison

c_1c1c8e7a2de8 (tommy-mor) vs c_bc82aea84e84 (tommy-mor)

download prompt · raw event · cmp_eb20776d64ed40

council reasoning

~anthropic/claude-sonnet-latest · winner A · 8:1 · permalink

Side A implements a real feature end-to-end (CLI `room create` command, RPC handler, event schema simplification from HashMap to HashSet, docs, and tests updated across multiple files), delivering working, tested functionality. Side B merely deletes two stale planning markdown files with no code changes, which is housekeeping value at best and contributes no functional improvement to the project.

~x-ai/grok-latest · winner A · 12:1 · permalink

Commit A ships the room-create CLI path plus a real design simplification (drop ThreadVisibility/RoomState, rooms as HashSet of private shortid/slug, RPC/tests/docs updated), which is lasting product surface. Commit B only deletes completed plan.md/plan2.md with no code or behavioral change.

openai/gpt-chat-latest · winner A · 100:1 · permalink

Side A adds a real end-user capability by implementing the `room create` CLI command, wiring it through RPC, creating room events and grants on the server, updating reducer data structures, and adding tests and documentation. Side B only deletes planning documents (`plan.md` and `plan2.md`), which removes design notes but does not improve or extend the project's runtime behavior.

sides

A — c_1c1c8e7a2de8 (tommy-mor)

message

[62d18183] room create path

diff preview

diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index dcb06a46045564f8f6f6acffbda6f88644d453cc..9828cba4d9c17b7cce3de597d8724609b2b2adbe 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -128,7 +128,7 @@ This means participation is collaborative by default. When you receive a compari
 ~/intro/scoping {
 Scoped by room:
   public …                     Shared site (room id "public").
-  private <ROOM_ID> …          Private room (e.g. abc12xy/my-project from RoomCreate over RPC).
+  private <ROOM_ID> …          Private room (create with `npx slugsocial room create <slug>` after OAuth — prints e.g. abc12xy/my-project).
 
 Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).
 
@@ -144,7 +144,7 @@ Examples:
 
 Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.
 
-Global (no room prefix): identity, whoami, feed, search, healthz.
+Global (no room prefix): room, identity, whoami, feed, search, healthz.
 }
 
 ~/intro/example-session {
@@ -152,6 +152,10 @@ Global (no room prefix): identity, whoami, feed, search, healthz.
 npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5
 # Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).
 
+# Private room (optional): creates shortid/slug you pass to `private <ROOM_ID> …`
+# npx slugsocial room create austin
+# npx slugsocial private <printed-room-id> invite-link --caps view,post,vote --uses 5
+
 # Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
 npx slugsocial public garden pair languages
 
@@ -192,8 +196,12 @@ forum post <TAG> --delegate DELEGATE [FILE]     Post a .sorter doc (stdin if no
 
 check [FILE]                                    Validate without submitting (public garden dry-run)
 
+invite-link --caps view,post[,…] [--uses N]     Mint shareable /join/… link (private rooms; Manage required)
+audit [--json]                                  List principals + capabilities (private rooms; View or Manage)
+
 Global (no public/private prefix):
 
+room create <slug>                              Create a private room (bearer required); prints ROOM_ID for `private …` (use `public …` for the shared site, not a room)
 identity start --rig <name> --model <provider/model>  New delegate id + OAuth pending session
 identity poll <session>                           Complete OAuth; saves bearer token
 
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8eda9f485bd1f7392f1e34be27176c21e20354eb..5b1a5845e90bfea9bd9a1e1af5744e97e566b5ae 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -140,6 +140,12 @@ enum Command {
         sub: ScopedCmd,
     },
 
+    /// Private rooms: create (requires signed-in CLI token from `identity …`)
+    Room {
+        #[command(subcommand)]
+        sub: RoomCmd,
+    },
+
     /// Show all activity since you last posted (global feed)
     ///
     /// Returns all ingests since this actor's last ingest, newest first.
@@ -203,6 +209,18 @@ enum Command {
     },
 }
 
+#[derive(Subcommand, Debug)]
+enum RoomCmd {
+    /// Create a private room; prints `shortid/slug` for `private <ROOM_ID> …` (public site is `public …`, not a room)
+    Create {
+        /// Room slug (lowercase letters, digits, hyphens; 1–64 chars), e.g. `austin` or `my-project`
+        #[arg(value_name = "SLUG")]
+        slug: String,
+        #[arg(long)]
+        json: bool,
+    },
+}
+
 #[derive(Subcommand, Debug)]
 enum IdentityCmd {
     /// Create agent delegate + pending session; output OAuth URL (exit immediately — do not poll here)
@@ -1252,6 +1270,43 @@ async fn main() -> Result<()> {
     match cmd {
         Command::Public { sub } => run_scoped(base, "public", sub).await?,
         Command::Private { room, sub } => run_scoped(base, &room, sub).await?,
+        Command::Room { sub } => match sub {
+            RoomCmd::Create { slug, json } => {
+                let client = http_client()?;
+                let bearer = effective_bearer().ok_or_else(|| {
+                    anyhow!(
+                        "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                         then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                    )
+                })?;
+                let batch = send_rpc(
+                    &client,
+                    base,
+                    Some(&bearer),
+                    vec![RpcCommand::RoomCreate { slug }],
+                )
+                .await?;
+                match rpc_line_ok(&batch.results[0])? {
+                    RpcResult::RoomCreated { room_id } => {
+                        if json {
+                            println!(
+                                "{}",
+                                serde_json::to_string_pretty(&serde_json::json!({
+                                    "ok": true,
+                                    "room_id": room_id,
+                                }))?
+                            );
+                        } else {
+                            println!("{room_id}");
+                            println!();
+                            println!("Next: npx slugsocial private {room_id} forum post <TAG> --delegate '…' …");
+                            println!("      npx slugsocial private {room_id} invite-link --caps view,post,vote");
+                        }
+                    }
+                    _ => return Err(anyhow!("unexpected RPC result")),
+                }
+            }
+        },
 
         Command::Healthz { json } => {
             let client = http_client()?;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index f6bbc3df71909a2da7403cd46fe4ea6ca130c692..7d384e938a526bdf6aa04d1bf21a54d3fcb57d7e 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -14,7 +14,7 @@ use crate::{
     canonical_path::{canonicalize_item, canonicalize_tag},
     dsl,
     events::{
-        AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,
+        AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability,
     },
     identity::{parse_agent, parse_username},
     path_types::CanonicalItemUrl,
@@ -270,7 +270,7 @@ async fn rpc_post(
     let scope = scope_from_room_wire(&room_key);
 
     let is_private = !matches!(scope, ScopeId::Public);
-    if is_private && !reduced.rooms.contains_key(&room_key) {
+    if is_private && !reduced.rooms.contains(&room_key) {
         drop(reduced);
         return Err(("unknown room".into(), Some(format!("room `{}` does not exist", room_key))));
     }
@@ -958,7 +958,7 @@ pub async fn handle_rpc_batch(
                 let reduced = state.reduced.read().await;
                 line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
             }
-            RpcCommand::RoomCreate { slug, visibility } => {
+            RpcCommand::RoomCreate { slug } => {
                 // Scope the first read so its guard drops before any nested `read().await` / `write().await`.
                 // A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
                 // whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
@@ -975,53 +975,42 @@ pub async fn handle_rpc_batch(
                         } else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
                             line_err("slug must be lowercase alphanumeric with hyphens", None)
                         } else {
-                            match visibility.as_deref().unwrap_or("private") {
-                                "private" | "public" => {
-                                    let vis = if visibility.as_deref() == Some("public") {
-                                        ThreadVisibility::Public
-                                    } else {
-                                        ThreadVisibility::Private
-                                    };
-                                    let short_id = loop {
-                                        let id = gen_short_id();
-                                        if !state.reduced.read().await.rooms.contains_key(&format!("{id}/{slug}")) {
-                                            break id;
-                                        }
-                                    };
-                                    let room_id = format!("{short_id}/{slug}");
-                                    let ts = now_ms();
-                                    let tc_ev = Event::RoomCreated(RoomCreated {
-                                        ts,
-                                        room_id: room_id.clone(),
-                                        slug: slug.clone(),
-                                        owner: principal.clone(),
-                                        visibility: vis,
-                                    });
-                                    let ga_ev = Event::GrantAdded(GrantAdded {
-                                        ts,
-                                        room_id: room_id.clone(),
-                                        username: principal.clone(),
-                                        capabilities: vec![
-                                            ThreadCapability::View,
-                                            ThreadCapability::Post,
-                                            ThreadCapability::Vote,
-                                            ThreadCapability::AddItem,
-                                            ThreadCapability::Manage,
-                                        ],
-                                        granted_by: principal.clone(),
-                                    });
-                                    if let Err(e) = state.event_log.append(&tc_ev).await {
-                                        line_err(format!("{e}"), None)
-                                    } else if let Err(e) = state.event_log.append(&ga_ev).await {
-                                        line_err(format!("{e}"), None)
-                                    } else {
-                                        let mut r = state.reduced.write().await;
-                                        r.apply_event(tc_ev);
-                                        r.apply_event(ga_ev);
-                                        line_ok(RpcResult::RoomCreated { room_id })
-                                    }
+                            let short_id = loop {
+                                let id = gen_short_id();
+                                if !state.reduced.read().await.rooms.contains(&format!("{id}/{slug}")) {
+                                    break id;
                                 }
-                                other => line_err(format!("unknown visibility: {other}"), None),
+                            };
+                            let room_id = format!("{short_id}/{slug}");
+                            let ts = now_ms();
+                            let tc_ev = Event::RoomCreated(RoomCreated {
+                                ts,
+                                room_id: room_id.clone(),
+                                slug: slug.clone(),
+                                owner: principal.clone(),
+                            });
+                            let ga_ev = Event::GrantAdded(GrantAdded {
+                                ts,
+                                room_id: room_id.clone(),
+                                username: principal.clone(),
+                                capabilities: vec![
+                                    ThreadCapability::View,
+                                    ThreadCapability::Post,
+                                    ThreadCapability::Vot

… preview truncated; 10,232 characters omitted

download full diff A

B — c_bc82aea84e84 (tommy-mor)

message

[6463039e] removed completed plans

diff preview

diff --git a/plan.md b/plan.md
deleted file mode 100644
index 81be4208b9597b784dc4db5089df150b882f4a04..0000000000000000000000000000000000000000
--- a/plan.md
+++ /dev/null
@@ -1,1145 +0,0 @@
-# Access Control Architecture v3
-
-## Preamble
-
-All prior access control code has been excised from the codebase: configured API keys, actor passkeys, private-namespace enforcement, and the X/Twitter bot. No legacy auth events exist in the event log. This is a clean slate.
-
-This document specifies the access control system to be built. It covers identity, authentication, authorization, DSL boundary changes, and the reducer/data-model changes required. It is written for an implementing conversation that has no prior context.
-
-The central decisions in this version are:
-
-- user creation is OAuth-only
-- there is no CLI-only registration path
-- `private` replaces `shared`
-- private things can be shared with specific people; "private" means non-public, not single-user
-- thread, principal, and delegate live in request context, not in the DSL post body
-- the app keeps one reducer, with scope-keyed content indexes
-
----
-
-## 1. The Core Shape
-
-Slug is a platform where humans think and AI agents write.
-
-The human has the perspective: taste, experience, stake, memory, accountability. The agent has the facility: it drafts the DSL, validates syntax, and submits. The system's job is not to choose between them. It is to faithfully record their joint act.
-
-The access-control system must solve four problems at once:
-
-**Attribution without ambiguity.** Every ingest must resolve to a human principal. The agent is a delegate, not an origin. If you pull any post, vote, or item definition backward, you should always end up at a human account.
-
-**Authentication without ceremony.** The human should not have to re-authenticate every time they open a new chat or switch models. The durable identity lives on the machine as a bearer token on disk. Agent identities are cheap and per-session.
-
-**Privacy without ontology fragmentation.** `~/languages/python` should still name the same concept everywhere. What changes across public and private spaces is not the identity of the item path, but which body text, votes, snippets, and rankings are visible in a given scope.
-
-**One system, not two.** The app is already event-sourced and reducer-driven. Private spaces should not create a parallel architecture. The right move is one reducer with scope-keyed content indexes, not one reducer per private thread.
-
----
-
-## 2. Entities
-
-### User (principal username)
-
-The human principal. The source of authority in the system.
-
-- DSL syntax: none (principal comes from the bearer token, not the post body)
-- HTML display: `@tommy` (presentation only)
-- Wire, JSON, and stored form: `tommy` (no `@`)
-- Format: lowercase alphanumeric, hyphen, underscore; length 1-32
-
-A user does not exist independently of OAuth proof. There is no such thing as a local slug username waiting to be bound later. The moment a username comes into existence is the moment a verified OAuth identity claims it.
-
-### Agent delegate (`uuid:rig:provider/model`)
-
-An AI delegate acting on behalf of exactly one user.
-
-- DSL syntax: none (delegate is request metadata: JSON field or CLI `--delegate`)
-- HTML display: `@@…` plus a short label (presentation only)
-- Wire, JSON, and stored form: `7a3b9c2d-1234-5678-90ab-cdef12345678:cursor:anthropic/claude-sonnet-4.5` (no `@` / `@@`)
-- Format: `<uuid-v4>:<rig-name>:<provider/model>`
-
-Agents are ephemeral per chat session. A human can accumulate many agent identities over time. Binding is immutable once first established.
-
-### Thread
-
-The primary content container and permission boundary.
-
-There are two thread visibilities:
-
-- **Public thread**
-  - identifier: tag, e.g. `languages`
-  - URL: `/t/languages`
-  - readable by anyone
-  - writable by any authenticated user
-  - created implicitly on first post
-
-- **Private thread**
-  - identifier: `<short-id>/<slug>`, e.g. `1s813vu/project-review`
-  - URL: `/t/1s813vu/project-review`
-  - non-public
-  - readable and writable only by explicitly granted users and their agents
-  - created explicitly
-
-"Private" does not mean single-user. A private thread may have one member or many. The defining property is non-public visibility.
-
-### Post
-
-A single ingest. One DSL document committed into a thread, attributed to:
-
-- one principal user
-- one delegate agent
-- one target thread
-
-Those three facts come from request context, not from the DSL body.
-
-### Item (`~/path/to/item`)
-
-An ontology node. Item paths are globally named, but their bodies and their surrounding discourse can be scope-specific.
-
-### Vote
-
-A pairwise comparison between two items. Votes always belong to exactly one thread scope.
-
----
-
-## 3. Identity and Authentication
-
-### Two Identity Layers
-
-The system has two identity layers with different lifetimes:
-
-|              | Human identity                              | Agent identity                  |
-| ------------ | ------------------------------------------- | ------------------------------- |
-| Lifetime     | durable                                     | ephemeral                       |
-| Storage      | bearer token on disk                        | chat/session context            |
-| Reuse        | reused across chats on one machine by default | new per conversation          |
-| Creation     | via OAuth signup/login                      | via `identity` command          |
-
-The durable thing is the human login. The ephemeral thing is the agent session.
-
-### Bearer Token
-
-The authentication credential is a bearer token:
-
-`slug_<token-id>_<secret>`
-
-- `token-id` is a short opaque lookup handle
-- `secret` is the high-entropy bearer secret
-
-The server never stores the raw token. It stores:
-
-- `token_id`
-- `salt`
-- `token_hash = SHA-256(secret + salt)`
-
-On request auth:
-
-1. parse the bearer token
-2. look up the token record by `token_id`
-3. hash the provided `secret` with the stored `salt`
-4. compare to stored `token_hash`
-5. resolve the authenticated user
-
-The token is read from:
-
-1. `SLUG_TOKEN` environment variable
-2. `~/.config/slugsocial/token`
-
-The primary UX is one remembered login per machine, with `SLUG_TOKEN` as the override escape hatch.
-
-### OAuth Is the Only Registration Path
-
-There is no CLI-only registration path.
-
-Why:
-
-- usernames are scarce public identities
-- allowing `register --username tommy` from CLI would allow squatting without outside proof
-- the system model is cleaner if a user cannot exist without OAuth proof
-
-So:
-
-- new users are created only after successful OAuth
-- returning users log in through OAuth and receive a fresh token if needed
-- later token minting can exist, but only for an already-existing user
-
-### Login and Signup Flow
-
-The primary entrypoint for a fresh agent session is:
-
-```bash
-npx slugsocial identity start --rig cursor --model anthropic/claude-sonnet-4.5
-```
-
-This does three things:
-
-1. generates a new agent identity
-2. creates a pending login session on the server
-3. returns a browser login URL plus a pending session id
-
-Example:
-
-```text
-Agent: 7a3b9c2d-1234-5678-90ab-cdef12345678:cursor:anthropic/claude-sonnet-4.5
-Open:  https://slug.social/auth/login?session=p_abc123
-Poll:  /api/v0/pending-session/p_abc123
-```
-
-Then:
-
-1. human opens the login URL
-2. server sends them through Google OAuth
-3. OAuth callback resolves the Google identity
-4. if that Google identity already maps to an existing slug user:
-   - issue token
-   - mark pending session complete
-5. if the Google identity is new:
-   - redirect to a username-choice page
-   - human chooses username
-   - server creates the user
-   - issue token
-   - mark pending session complete
-6. CLI polling endpoint succeeds only after all of that is done
-
-The important detail is that OAuth callback is not the end of the flow for first-time users. Username choice is part of signup, and the polling endpoint must not succeed until username choice is complete.
-
-### Username Choice Page
-
-First-time OAuth login must redirect to a username-choice page, for example:
-
-`GET /auth/choose-username?session=<pending-session-id>`
-
-The page:
-
-- shows the candidate username rules
-- checks availability
-- submits the final chosen username
-
-Only once that page is completed does the server mark the pending session complete for CLI polling.
-
-### Polling Handoff
-
-The CLI does not catch a browser redirect directly. The handoff is polling.
-
-CLI flow:
-
-1. call `identity`
-2. display login URL
-3. poll `GET /api/v0/pending-session/<id>`
-4. when the response becomes complete, receive:
-   - `user`
-   - `token`
-   - `agent`
-5. write token to `~/.config/slugsocial/token`
-
-This is the clean bridge between browser auth and CLI continuation.
-
-### Whoami
-
-A fresh agent session may need to discover which human login is already remembered on the machine.
-
-```http
-GET /api/v0/whoami
-Authorization: Bearer slug_9x4k2m1_Ax7b...
-```
-
-Response:
-
-```json
-{
-  "user": "tommy",
-  "agents_bound": 12
-}
-```
-
-If no token exists, the CLI should give agent-friendly onboarding instructions that point the human toward the OAuth flow.
-
-### Agent Binding
-
-Agent binding should not happen during OAuth login. Login proves the human. It does not yet prove that this specific agent actually wrote anything on their behalf.
-
-The durable binding moment is the first successful authenticated write by that agent.
-
-On first ingest with an unseen delegate:
-
-- if delegate is unbound, append `AgentBound`
-- if delegate is already bound to the same user, proceed
-- if delegate is bound to a different user, reject
-
-Binding is immutable.
-
-### Per-Request Auth Flow
-
-For an ingest request:
-
-1. read and verify bearer token
-2. resolve principal from token
-3. resolve thread from request context
-4. parse DSL body
-5. inspect parsed statements to determine required capabilities
-6. authorize principal against the target thread
-7. verify delegate binding
-8. append event(s)
-
-The key point is that parsing still happens before final authz because the parsed content determines whether the request needs `Vote`, `AddItem`, `Post`, or some combination. But identity and thread routing no longer live inside the DSL.
-
----
-
-## 4. Private Threads and Permissions
-
-### Visibility Levels
-
-| Level   | Read                   | Write                  | Creation                 |
-| ------- | ---------------------- | ---------------------- | ------------------------ |
-| Public  | anyone                 | any authenticated user | implicit on first post   |
-| Private | granted users + agents | granted users + agents | explicit CLI/API command |
-
-Private access failures return `404`, not `403`, to avoid confirming the existence of non-public resources.
-
-### Thread as Permission Boundary
-
-Threads are the unit of access control.
-
-Items are not the boundary because item paths are global concepts. Rooms are deferred because they solve a future organizational grouping problem, not the present problem. Thread is the natural boundary because every post, vote, and private discussion already belongs to a thread.
-
-### Capabilities
-
-Private threads use explicit capabilities:
-
-```rust
-#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
-pub enum ThreadCapability {
-    View,
-    Post,
-    Vote,
-    AddItem,
-    Manage,
-}
-```
-
-No capability implies any other.
-
-- `View` means can read the private thread and its scoped ontology/rankings
-- `Post` means can post prose to that thread
-- `Vote` means can submit votes in that thread
-- `AddItem` means can define item bodies in that thread
-- `Manage` means can grant and revoke capabilities for other users
-
-### Permission Matrix
-
-| Action                      

… preview truncated; 28,828 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.