constitution · epochs · watch · epoch 3

comparison

c_45659f04aa7c (tommy-mor) vs c_55f1cdf12e22 (tommy-mor)

download prompt · raw event · cmp_19afb45ec30eb5

council reasoning

~anthropic/claude-sonnet-latest · winner A · 6:4 · permalink

Side A replaces a fragile single-repo GitHub-API polling mechanism with a deterministic, replayable, multi-repository git-discovery engine that handles adversarial cases (force-push reintroduction, git-replace forgery, cherry-pick patch-identity dedup, merge/empty-commit exclusion) and ships extensive property/stateful tests validating these invariants — this is core infrastructure for the constitution's fairness/correctness guarantees. Side B adds a well-tested invite feature to the product, which is useful but more routine feature work with less foundational impact on the system's integrity guarantees.

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

A replaces fragile GitHub API polling with deterministic multi-repo git mirroring, patch-id dedup, genesis/force-push/cherry-pick rules, and large unit/stateful/integration coverage—core protocol design for attribution. B adds a solid but narrower product feature (ephemeral invite links, RoomAudit, CLI, grant timeline merge) without comparable adversarial depth or foundational correctness work.

openai/gpt-chat-latest · winner A · 5:2 · permalink

Side A replaces GitHub API-based commit fetching with a deterministic multi-repository Git discovery pipeline, including mirrored repositories, replayable reachability snapshots, patch-ID deduplication, discovery events persisted to the ledger, concurrency/file locking, and extensive real-Git integration and property tests. Side B adds a useful invite system, room audit RPCs, and timeline/UI support, but it also leaves parts incomplete (for example `RoomRevoke` is explicitly unimplemented and invite events are defined but the implementation relies on ephemeral in-memory state), making it a narrower and less foundational contribution.

sides

A — c_45659f04aa7c (tommy-mor)

message

[1efb7222] multi-repository git discovery

Make contribution discovery deterministic across repository and branch DAGs, with replayable attribution and adversarial coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/constitution.py b/constitution.py
index b535faad658c553b3b9046e2401333618e212150..4bee9f83663ab7fb36db95129b92b64b4ef57258 100644
--- a/constitution.py
+++ b/constitution.py
@@ -6,7 +6,7 @@
 #   "uvicorn",
 #   "httpx",
 #   "tenacity",
-#   "evaleval>=0.2.6",
+#   "evaleval==0.2.7",
 #   "authlib",
 #   "itsdangerous",
 #   "starlette",
@@ -29,7 +29,7 @@ from datetime import datetime, timezone
 from fastapi import FastAPI, Request, Response
 from fastapi.responses import PlainTextResponse, HTMLResponse
 from starlette.middleware.sessions import SessionMiddleware
-import json, time, os, asyncio, httpx, pathlib
+import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl
 import sympy as sp  # type: ignore[reportMissingImports]
 from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
 from evaleval import (
@@ -118,11 +118,35 @@ JSONL_PATH = pathlib.Path(os.environ.get("JSONL_PATH", "/data/ledger.jsonl"))
 
 GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
 GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
-REPO = os.environ.get("REPO", "tommy-mor/slug")
 
 OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
 OPENROUTER_BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai").rstrip("/")
-GITHUB_API_BASE_URL = os.environ.get("GITHUB_API_BASE_URL", "https://api.github.com").rstrip("/")
+
+# Repositories, branches, and contributor identities are constitutional inputs.
+# A ref pattern matches a complete Git ref: * stays within one path component,
+# while ** crosses slashes, so refs/heads/** includes branches on branches.
+# Environment overrides exist for deterministic integration tests and deployments
+# using the exact same source; their normalized values are committed to every
+# discovery event.
+DEFAULT_REPOSITORIES = [
+    {
+        "id": "slug",
+        "url": "https://github.com/tommy-mor/slug.git",
+        "refs": ["refs/heads/**"],
+    },
+]
+DEFAULT_CONTRIBUTORS = {
+    "tommy-mor": ["thmorriss@gmail.com"],
+}
+
+REPOSITORIES = json.loads(
+    os.environ.get("REPOSITORIES_JSON", json.dumps(DEFAULT_REPOSITORIES))
+)
+CONTRIBUTORS = json.loads(
+    os.environ.get("CONTRIBUTORS_JSON", json.dumps(DEFAULT_CONTRIBUTORS))
+)
+GIT_MIRROR_DIR = pathlib.Path(os.environ.get("GIT_MIRROR_DIR", "/data/git"))
+GIT_TIMEOUT_SECONDS = int(os.environ.get("GIT_TIMEOUT_SECONDS", "120"))
 
 # Council model IDs: slug.social garden rank under this parent (bodies = OpenRouter URLs), then top-up from OpenRouter list.
 SLUG_SOCIAL_BASE_URL = os.environ.get("SLUG_SOCIAL_BASE_URL", "https://slug.social").rstrip("/")
@@ -146,6 +170,7 @@ class Emission:
     distributions: dict   # author -> amount str
     ranking: dict         # author -> score str
     models_used: list
+    discovery_snapshot_id: str = ""  # empty only for pre-discovery ledger history
 
 
 @event
@@ -163,6 +188,20 @@ class Redemption:
     amount: str
 
 
+@event
+class GitDiscovery:
+    schema_version: int
+    epoch: int
+    snapshot_id: str
+    timestamp_ms: int
+    config_digest: str
+    initial_snapshot: bool
+    configuration: dict
+    repositories: list
+    observations: list
+    commits: list
+
+
 store = JsonlStore(JSONL_PATH)
 
 
@@ -532,44 +571,426 @@ Side B — unified diffs (full patches):
         return json.loads(content)
 
 
-def _github_headers():
-    h = {"Accept": "application/vnd.github+json"}
-    tok = os.environ.get("GITHUB_TOKEN", "")
-    if tok:
-        h["Authorization"] = f"Bearer {tok}"
-    return h
+# ===========================================================================
+# §4b. GIT DISCOVERY — immutable reachability snapshots across repositories
+# ===========================================================================
+#
+# Git timestamps cannot prove when a branch first reached a commit. The first
+# snapshot therefore bootstraps history by committer time at GENESIS_MS. Every
+# later snapshot uses the stronger rule: a commit enters exactly once, when it
+# first becomes reachable from the union of configured refs.
+#
+# OIDs are deduplicated globally, then equivalent cherry-picks are deduplicated
+# by Git's stable patch identity. Merges and empty commits are graph structure,
+# not separately priced contributions. Discovery is all-or-nothing: if any
+# repository cannot be mirrored and verified, no snapshot is appended.
+
+GIT_DISCOVERY_SCHEMA_VERSION = 1
+PATCH_IDENTITY_VERSION = "git-patch-id-stable-v1"
+_DISCOVERY_LOCK = asyncio.Lock()
+
+
+def _normalized_discovery_config() -> dict:
+    repositories = []
+    seen_ids = set()
+    for raw in REPOSITORIES:
+        repo_id = str(raw.get("id", ""))
+        url = str(raw.get("url", ""))
+        refs = sorted(set(str(x) for x in raw.get("refs", [])))
+        if not re.fullmatch(r"[A-Za-z0-9._-]+", repo_id):
+            raise ValueError(f"invalid repository id: {repo_id!r}")
+        if repo_id in seen_ids:
+            raise ValueError(f"duplicate repository id: {repo_id}")
+        if not url or not refs or any(not r.startswith("refs/") for r in refs):
+            raise ValueError(f"repository {repo_id} requires a URL and full ref patterns")
+        seen_ids.add(repo_id)
+        repositories.append({"id": repo_id, "url": url, "refs": refs})
+
+    email_to_contributor = {}
+    contributors = {}
+    for contributor, emails in sorted(CONTRIBUTORS.items()):
+        contributor = str(contributor)
+        normalized = sorted(set(str(e).strip().lower() for e in emails))
+        if not contributor or not normalized:
+            raise ValueError("contributors require an id and at least one email")
+        for email in normalized:
+            if email in email_to_contributor:
+                raise ValueError(f"email belongs to multiple contributors: {email}")
+            email_to_contributor[email] = contributor
+        contributors[contributor] = normalized
+
+    repositories.sort(key=lambda r: r["id"])
+    return {"repositories": repositories, "contributors": contributors}
+
+
+def _config_digest(config: dict) -> str:
+    encoded = json.dumps(config, sort_keys=True, separators=(",", ":")).encode()
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def _ref_pattern_regex(pattern: str) -> re.Pattern:
+    out = ""
+    i = 0
+    while i < len(pattern):
+        if pattern[i:i + 2] == "**":
+            out += ".*"
+            i += 2
+        elif pattern[i] == "*":
+            out += "[^/]*"
+            i += 1
+        elif pattern[i] == "?":
+            out += "[^/]"
+            i += 1
+        else:
+            out += re.escape(pattern[i])
+            i += 1
+    return re.compile(f"^{out}$")
+
+
+def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None) -> bytes:
+    command = [
+        "git",
+        "--no-replace-objects",
+        "-c", "core.quotepath=true",
+        "-c", "core.attributesFile=/dev/null",
+        "-c", "diff.external=",
+        "-c", "diff.renames=false",
+        "-c", "diff.algorithm=myers",
+        "-c", "diff.context=3",
+    ]
+    if repo is not None:
+        command += ["-C", str(repo)]
+    command += list(args)
+    try:
+        result = subprocess.run(
+            command,
+            input=input_bytes,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            env={
+                **os.environ,
+                "GIT_CONFIG_NOSYSTEM": "1",
+                "GIT_CONFIG_GLOBAL": os.devnull,
+                "GIT_NO_REPLACE_OBJECTS": "1",
+                "LC_ALL": "C",
+                "TZ": "UTC",
+            },
+            timeout=GIT_TIMEOUT_SECONDS,
+            check=False,
+        )
+    except subprocess.TimeoutExpired as exc:
+        raise RuntimeError(f"git command timed out: {args[0]}") from exc
+    if result.returncode:
+        error = result.stderr.decode("utf-8", "replace").strip()
+        raise RuntimeError(f"git {args[0]} failed: {error}")
+    return result.stdout
+
+
+def _ensure_mirror(repo: dict) -> pathlib.Path:
+    GIT_MIRROR_DIR.mkdir(parents=True, exist_ok=True)
+    mirror = GIT_MIRROR_DIR / f"{repo['id']}.git"
+    if not mirror.exists():
+        _git(None, "clone", "--mirror", "--", repo["url"], str(mirror))
+    else:
+        actual_url = _git(mirror, "remote", "get-url", "origin").decode().strip()
+        if actual_url != repo["url"]:
+            raise RuntimeError(
+                f"mirror URL mismatch for {repo['id']}: {actual_url!r}"
+            )
+    _git(mirror, "fetch", "--prune", "origin", "+refs/*:refs/*")
+    _git(mirror, "fsck", "--connectivity-only", "--no-dangling")
+    return mirror
+
+
+def _matching_refs(mirror: pathlib.Path, patterns: list[str]) -> list[dict]:
+    regexes = [_ref_pattern_regex(p) for p in patterns]
+    lines = _git(
+        mirror, "for-each-ref", "--format=%(refname)%00%(objectname)"
+    ).decode("utf-8", "replace").splitlines()
+    selected = []
+    for line in lines:
+        if not line:
+            continue
+        ref_name, direct_oid = line.split("\x00", 1)
+        if not any(r.fullmatch(ref_name) for r in regexes):
+            continue
+        commit_oid = _git(
+            mirror, "rev-parse", "--verify", f"{ref_name}^{{commit}}"
+        ).decode().strip()
+        selected.append({
+            "name": ref_name,
+            "direct_oid": direct_oid,
+            "commit_oid": commit_oid,
+        })
+    if not selected:
+        raise RuntimeError(f"no refs matched patterns {patterns!r}")
+    return sorted(selected, key=lambda r: r["name"])
+
+
+def _commit_metadata(mirror: pathlib.Path, oid: str) -> dict:
+    raw = _git(
+        mirror,
+        "show",
+        "-s",
+        "--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B",
+        oid,
+    ).decode("utf-8", "replace")
+    fields = raw.split("\x00", 9)
+    if len(fields) != 10:
+        raise RuntimeError(f"could not parse commit metadata for {oid}")
+    return {
+        "oid": fields[0],
+        "tree_oid": fields[1],
+        "parent_oids": fields[2].split() if fields[2] else [],
+        "author_name": fields[3],
+        "author_email": fields[4].strip().lower(),
+        "author_timestamp_ms": int(fields[5]) * 1000,
+        "committer_name": fields[6],
+        "committer_email": fields[7].strip().lower(),
+        "committer_timestamp_ms": int(fields[8]) * 1000,
+        "message": fields[9].rstrip("\n"),
+    }
 
 
-def unified_diff_from_commit_payload(payload: dict) -> str:
-    parts = []
-    for f in payload.get("files") or []:
-        name = f.get("filename", "?")
-        patch = f.get("patch")
-        if patch:
-            parts.append(f"--- {name}\n{patch}")
-        else:
-            parts.append(f"--- {name}\n[no textual patch: binary, submodule, or too large]\n")
-    return "\n\n".join(parts) if parts else "[no files in API response]"
+def _commit_patch(mirror: pathlib.Path, metadata: dict) -> tuple[str, str | None]:
+    parents = metadata["parent_oids"]
+    if len(parents) > 1:
+        return "", None
+    if parents:
+        args = ("diff", "--patch", "--binary", "--full-index", "--no-renames",
+                "--no-ext-diff", "--no-textconv", "--src-prefix=a/",
+                "--dst-prefix=b/", parents[0], metadata["oid"], "--")
+    else:
+        args = ("diff-tree", "--root", "--patch", "--binary", "--full-index",
+                "--no-renames", "--no-ext-diff", "--no-textconv",
+                "--src-prefix=a/", "--dst-prefix=b/", "--no-commit-id",
+                metadata["oid"], "--")
+    patch_bytes = _git(mirror, *args)
+    if not patch_bytes.strip():
+        return "", None
+    # Run patch-id outside the repository so SHA-1 and SHA-256 repositories use
+    # the same canonical patch hash algorithm.
+    patch_id_out = _git(
+        None, "patch-id", "--stable", input_bytes=patch_bytes
+    ).decode().strip()
+    if patch_id_out:
+        st

… preview truncated; 120,051 characters omitted

download full diff A

B — c_55f1cdf12e22 (tommy-mor)

message

[00be3a29] invite system

diff preview

diff --git a/bb.edn b/bb.edn
index 50be8232847e672b3f273a2fb25ddd1d12adb7e2..818f850765370d12d58f239e287764fc3649d78b 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,14 +47,16 @@
                                            "RUST_LOG"      "info"})})))}
 
   test
-  {:doc "Full test suite: integration + auth + grants"
+  {:doc "Full test suite: integration + auth + grants + invites"
    :requires ([test.integration :as integration]
               [test.auth :as auth]
-              [test.grants :as grants])
+              [test.grants :as grants]
+              [test.invites :as invites])
    :task (do
            (integration/integration)
            (auth/auth-test)
-           (grants/grants-test))}
+           (grants/grants-test)
+           (invites/invites-test))}
 
   perf
   {:doc "Performance test: concurrent HTTP requests to detect blocking I/O"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index e5833b0b93d8e667b94c574ba2b0f8cb758ff3df..8eda9f485bd1f7392f1e34be27176c21e20354eb 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -105,6 +105,23 @@ enum ScopedCmd {
         #[arg(long)]
         json: bool,
     },
+
+    /// Mint a shareable invite link (24h TTL, in-memory until redeemed). Requires Manage on the room.
+    InviteLink {
+        /// Comma-separated: view, post, vote, add_item, manage
+        #[arg(long = "caps", value_delimiter = ',')]
+        caps: Vec<String>,
+        #[arg(long, default_value_t = 1)]
+        uses: usize,
+        #[arg(long)]
+        json: bool,
+    },
+
+    /// List principals granted access in this room (requires View or Manage)
+    Audit {
+        #[arg(long)]
+        json: bool,
+    },
 }
 
 #[derive(Subcommand, Debug)]
@@ -514,17 +531,35 @@ fn print_thread(resp: &ThreadDetailResponse) {
         .duration_since(std::time::UNIX_EPOCH)
         .unwrap_or_default()
         .as_millis() as i64;
-    if resp.total > resp.posts.len() {
-        let end = resp.offset + resp.posts.len();
-        eprintln!("# showing {}-{} of {} posts  (--offset N --limit N to paginate)", resp.offset, end.saturating_sub(1), resp.total);
+    if resp.total > resp.items.len() {
+        let end = resp.offset + resp.items.len();
+        eprintln!(
+            "# showing {}-{} of {} rows  (--offset N --limit N to paginate)",
+            resp.offset,
+            end.saturating_sub(1),
+            resp.total
+        );
     }
-    for (i, post) in resp.posts.iter().enumerate() {
-        let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts);
-        let body = &post.body.trim();
-        println!("<post index=\"{}\" timeago=\"{}\">", post.index, timeago);
-        println!("{}", body);
-        println!("</post>");
-        if i + 1 < resp.posts.len() {
+    for (i, item) in resp.items.iter().enumerate() {
+        match item {
+            ThreadItem::Post {
+                index,
+                ts,
+                body,
+                ..
+            } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                let body = body.trim();
+                println!("<post index=\"{}\" timeago=\"{}\">", index, timeago);
+                println!("{}", body);
+                println!("</post>");
+            }
+            ThreadItem::System { ts, text } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                println!("<system timeago=\"{}\">{}</system>", timeago, text.trim());
+            }
+        }
+        if i + 1 < resp.items.len() {
             println!();
             println!();
         }
@@ -1036,6 +1071,95 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
                 }
             }
         },
+        ScopedCmd::InviteLink { caps, uses, json } => {
+            let caps: Vec<String> = caps
+                .into_iter()
+                .flat_map(|s| {
+                    s.split(',')
+                        .map(|p| p.trim().to_lowercase())
+                        .filter(|p| !p.is_empty())
+                        .collect::<Vec<_>>()
+                })
+                .collect();
+            if caps.is_empty() {
+                return Err(anyhow!("--caps is required (e.g. --caps view,post,vote)"));
+            }
+            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::RoomMintInvite {
+                    room: room.to_string(),
+                    capabilities: caps,
+                    max_uses: uses,
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomInviteMinted {
+                    invite_url,
+                    expires_at_ms,
+                    max_uses,
+                } => {
+                    if json {
+                        println!(
+                            "{}",
+                            serde_json::to_string_pretty(&serde_json::json!({
+                                "invite_url": invite_url,
+                                "expires_at_ms": expires_at_ms,
+                                "max_uses": max_uses,
+                            }))?
+                        );
+                    } else {
+                        println!("{invite_url}");
+                        println!("(Expires in 24 hours. Max uses: {max_uses})");
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
+        ScopedCmd::Audit { json } => {
+            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::RoomAudit {
+                    room: room.to_string(),
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomAudit(resp) => {
+                    if json {
+                        println!("{}", serde_json::to_string_pretty(&resp)?);
+                    } else {
+                        println!("room {}", resp.room);
+                        if resp.grants.is_empty() {
+                            println!("(no grants recorded)");
+                        } else {
+                            let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0);
+                            for g in &resp.grants {
+                                let caps = g.capabilities.join(", ");
+                                println!("{:<width$}  {}", g.username, caps, width = w_user.max(8));
+                            }
+                        }
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
         ScopedCmd::Check { file, json } => {
             let mut text = String::new();
             match file {
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 995ce4a61d29b024c399c656134f541ecfd880cf..b45ba39419c84af8bf2333fc9b7d47e98525c45f 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,12 +12,61 @@ use tokio::sync::RwLock;
 
 use crate::{
     api::helpers::{api_error, now_ms, sha256_hex},
-    events::{Event, TokenIssued, UserRegistered},
+    events::{Event, GrantAdded, TokenIssued, UserRegistered},
     identity::{parse_agent, parse_username},
     html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
 
+/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
+const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
+
+async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
+    let now = now_ms();
+    let ga = {
+        let mut invites = state.invites.write().await;
+        let Some(inv) = invites.get_mut(invite_token) else {
+            return Err("invite not found".into());
+        };
+        if now > inv.expires_at_ms {
+            invites.remove(invite_token);
+            return Err("invite expired".into());
+        }
+        if inv.current_uses >= inv.max_uses {
+            return Err("invite exhausted".into());
+        }
+        inv.current_uses += 1;
+        Event::GrantAdded(GrantAdded {
+            ts: now,
+            room_id: inv.room_id.clone(),
+            username: grantee_username.to_string(),
+            capabilities: inv.capabilities.clone(),
+            granted_by: inv.inviter.clone(),
+        })
+    };
+
+    match state.event_log.append(&ga).await {
+        Ok(()) => {
+            let mut reduced = state.reduced.write().await;
+            reduced.apply_event(ga);
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get(invite_token) {
+                if inv.current_uses >= inv.max_uses {
+                    invites.remove(invite_token);
+                }
+            }
+            Ok(())
+        }
+        Err(e) => {
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get_mut(invite_token) {
+                inv.current_uses = inv.current_uses.saturating_sub(1);
+            }
+            Err(format!("{e}"))
+        }
+    }
+}
+
 fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSession>>> {
     state.pending_sessions.clone()
 }
@@ -115,6 +164,42 @@ pub struct AuthLoginQuery {
     pub session: String,
 }
 
+pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+    let token = token.trim().to_string();
+    if token.is_empty() {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+    let now = now_ms();
+    let valid = {
+        let invites = state.invites.read().await;
+        match invites.get(&token) {
+            None => false,
+            Some(inv) => now <= inv.expires_at_ms && inv.current_uses < inv.max_uses,
+        }
+    };
+    if !valid {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+
+    let session = format!("p_{}", uuid::Uuid::new_v4().simple());
+    let s = PendingSession {
+        agent: INVITE_BROWSER_AGENT.to_string(),
+        created_ts: now_ms(),
+        provider: None,
+        provider_id: None,
+        redeem_invite: Some(token),
+        complete: None,
+    };
+    state.pending_sessions.write().await.insert(session.clone(), s);
+
+    let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
+    Redirect::temporary(&format!(
+        "{public_url}/auth/login?session={}",
+        urlencoding::encode(&session)
+    ))
+    .into_response()
+}
+
 pub async fn get_auth_login(Query(q): Query<AuthLoginQuery>, State(state): State<AppState>) -> impl IntoResponse {
     // Redirect to Google auth endpoint.
     let sessions = pending_sessions(&state);
@@ -205,6 +290,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
         s.provider = Som

… preview truncated; 45,403 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.