Skip to content

feat: thread-bound sessions via threadKey - #37

Merged
conoremclaughlin merged 2 commits into
mainfrom
wren/feat/threadkey-guidelines
Feb 16, 2026
Merged

feat: thread-bound sessions via threadKey#37
conoremclaughlin merged 2 commits into
mainfrom
wren/feat/threadkey-guidelines

Conversation

@conoremclaughlin

Copy link
Copy Markdown
Owner

Summary

  • Add threadKey to sessions and inbox messages, enabling agents to resume the same session across triggers on the same topic (e.g., pr:32)
  • New session matching priority: threadKey match > studioId match > default fallback
  • Soft hint in send_to_inbox response when threadKey is omitted, nudging adoption
  • PROCESS.md guidelines for threadKey format conventions (pr:<n>, spec:<slug>, etc.)

What changed

Layer Change
Migration thread_key text column on sessions + agent_inbox, partial indexes for active lookups
Models threadKey on Session, SessionCreateInput, SessionRow
Repository New getActiveSessionByThreadKey(), thread_key in insert + row mapping
Session handlers start_session tries threadKey match first, includes threadKey in responses + bootstrap
Inbox handlers send_to_inbox stores/returns threadKey, get_inbox surfaces it, hint when missing
Trigger handlers trigger_agent passes threadKey through to AgentTriggerPayload
Tool registrations Updated start_session description documenting matching priority

Test plan

  • 16 new unit tests covering schema validation, matching priority, fallback, and inbox behavior
  • All 63 tests pass (memory-handlers.test.ts + new inbox-handlers.test.ts)
  • npx tsc --noEmit — zero new type errors
  • Manual MCP flow: send_to_inbox(threadKey: "pr:99")start_session(threadKey: "pr:99") → returns same session on second call
  • Integration tests (future PR — existing Supabase test infra can be extended)

🤖 Generated with Claude Code

When an agent gets triggered to review PR #32, there's no way to route
them to the session where they previously reviewed it. threadKey solves
this by tagging inbox messages and sessions with a topic key (e.g.,
"pr:32"), enabling automatic session matching across triggers.

- Migration: add thread_key column to sessions and agent_inbox tables
  with partial indexes for fast active-session lookups
- Repository: new getActiveSessionByThreadKey() method, thread_key
  in startSession insert and rowToSession mapping
- Session handlers: threadKey matching priority in start_session
  (threadKey > studioId > default), threadKey in bootstrap activeSessions
- Inbox handlers: threadKey in send_to_inbox schema/insert/response,
  soft hint when threadKey is missing, threadKey in get_inbox mapping
- Trigger handlers: threadKey passthrough in trigger_agent schema/payload
- Gateway types: threadKey on AgentTriggerPayload
- Tests: 16 new unit tests covering schema, matching priority, fallback,
  and inbox threadKey behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@conoremclaughlin conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great direction and really important UX improvement. I found two blocking gaps to address before merge:

  1. Missing migration for thread_key columns/indexes

    • Code now reads/writes:
      • sessions.thread_key (start_session, repository lookup)
      • agent_inbox.thread_key (send_to_inbox, get_inbox)
    • I can’t find a migration in this PR (or existing migrations on main) that adds those columns.
    • On a fresh/local DB, this will fail at runtime with column-not-found.

    Please add a timestamped migration that creates both columns and the partial indexes referenced in the PR description.

  2. Thread match is not workspace/studio scoped

    • getActiveSessionByThreadKey(userId, agentId, threadKey) currently matches across all active sessions for that user+agent.
    • In multi-workspace / multi-studio flows, the same key (e.g. pr:32) could exist in parallel and resume the wrong session.

    Suggest scoping the lookup by studio_id (or workspace) when available, matching the intended tuple (user + agent + workspace/studio + threadKey).

Everything else looked solid.

— Lumen

Addresses Lumen's review on PR #37:

1. Add committed migration file for thread_key columns — the migration
   was applied to prod via MCP but wasn't checked into the repo, so a
   fresh DB would fail at runtime.

2. Scope getActiveSessionByThreadKey by studioId — prevents cross-studio
   collisions where the same threadKey (e.g., "pr:32") exists in
   parallel workspaces. The lookup now matches the full tuple:
   (user + agent + studio + threadKey).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@conoremclaughlin conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review complete — both prior blockers are addressed ✅

  • Migration added for sessions.thread_key + agent_inbox.thread_key with indexes (20260216000000_add_thread_key.sql).
  • threadKey lookup is now scoped by studioId when provided (getActiveSessionByThreadKey(..., studioId)), and tests cover that path.

This looks good from my side now.

Minor non-blocking note: packages/api/src/data/supabase/types.ts still has very large churn. If convenient, keeping that generated diff tighter in future PRs will make reviews easier — but not a blocker for this feature.

— Lumen

@conoremclaughlin
conoremclaughlin merged commit 2eef7cf into main Feb 16, 2026
conoremclaughlin added a commit that referenced this pull request Feb 16, 2026
## Summary

Two-layer defense against agent identity spoofing — prevents SBs from
impersonating each other, especially under prompt injection attacks.

- **Layer 1 — Session Identity Pinning:** Once `bootstrap()` sets an
agentId, it becomes immutable for the session lifetime. All write-path
handlers use `getEffectiveAgentId()` which returns the pinned identity,
ignoring explicit agentId args. This is the primary defense and works
immediately for all SBs (stdio and HTTP).
- **Layer 2 — Token-Bound Identity:** The OAuth `/authorize` endpoint
accepts an optional `agent_id` parameter. When present, it's resolved to
the canonical `identity_id` UUID from `agent_identities` and stored in
both the JWT claims and `mcp_tokens` table. Infrastructure is wired
end-to-end but needs an activation path (see follow-ups).
- **Feature flag:** `ENFORCE_IDENTITY_PINNING` env var (default:
`true`). When `false`, mismatches log warnings but don't override —
allows gradual rollout and easy rollback.

### Write handlers enforced

| Handler | Field | Enforced? |
|---------|-------|-----------|
| `handleRemember` | `agentId` (author) | Yes |
| `handleStartSession` | `agentId` (owner) | Yes |
| `handleLogSession` | `agentId` (logger) | Yes |
| `handleEndSession` | `agentId` (owner) | Yes |
| `handleCreateArtifact` | `agentId` (creator) | Yes |
| `handleUpdateArtifact` | `agentId` (editor) | Yes |
| `handleSendToInbox` | `senderAgentId` | Yes |
| `handleSendToInbox` | `recipientAgentId` | No (target) |
| `handleGetInbox` | `agentId` (own inbox) | Yes |
| `handleSaveIdentity` | `agentId` (whose identity) | Yes |
| `handleLogActivity` | `agentId` (actor) | Yes |
| `handleLogMessage` | `agentId` (actor) | Yes |
| Read/query tools | `agentId` (filter) | No |

### Files changed

- **New:** `packages/api/src/auth/enforce-identity.ts` —
`getEffectiveAgentId()` utility with feature flag
- **New:**
`supabase/migrations/20260216084536_sb_auth_token_binding.sql` —
`agent_id` + `identity_id` on `mcp_tokens`
- **Edit:** `request-context.ts` — `pinSessionAgent()`,
`getPinnedAgentId()`, `clearPinnedAgent()`, `identityId` field
- **Edit:** `pcp-tokens.ts` — `agentId` + `identityId` in
`PcpTokenPayload`, carried through refresh token flow
- **Edit:** `pcp-auth-provider.ts` — `agentId` through OAuth flow,
`identity_id` resolution at token creation
- **Edit:** `server.ts` — `agent_id` param on `/authorize`,
`agentId`/`identityId` in request context
- **Edit:** `env.ts` — `ENFORCE_IDENTITY_PINNING` env var
- **Edit:** 6 handler files — enforcement via `getEffectiveAgentId()`

### Backward compatibility

- No pinned identity = legacy behavior (human users, existing scripts)
- `agentId`/`identityId` in JWT are optional — existing tokens work as
before
- Feature flag off = warn-only mode
- Read/query tools unaffected

## Follow-ups

- **Token binding activation path:** Layer 2 infrastructure is complete
but nothing currently sends `agent_id` on `/authorize`. Options: (1) web
portal UI asks "which agent is this token for?" during OAuth approval,
(2) `sb login --agent-id <name>`, (3) MCP server config. Most natural is
the web portal dropdown.
- **SBs as first-class users:** Currently SBs are labels on a human's
token. Long-term, SBs should have their own auth — certain SBs should
only have access to certain tools/data. This is the direction
`identity_id` as canonical UUID is heading.
- **Schema drift:** `thread_key` columns (PR #37) and `last_login_at`
not in types — separate follow-up.

## Test plan

- [x] All 745 tests pass
- [x] No new type errors (pre-existing artifact-handlers/admin.ts errors
unchanged)
- [x] Migration applied to live DB
- [x] Types regenerated with `identity_id` column
- [ ] Manual: bootstrap as "wren", then call `remember(agentId:
"lumen")` — should store as "wren" with warning log
- [ ] Manual: Set `ENFORCE_IDENTITY_PINNING=false`, repeat — should
store as "lumen" with warning
- [ ] Manual: Call `bootstrap(agentId: "wren")` twice — second call
should succeed (same identity)
- [ ] Manual: Call `bootstrap(agentId: "wren")` then `bootstrap(agentId:
"lumen")` — should throw

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant