Skip to content

Commit 3004583

Browse files
henrypark133claude
andauthored
feat(ownership): centralized ownership model with typed identities, DB-backed pairing, and OwnershipCache (#1898)
* feat(ownership): add OwnerId, Identity, UserRole, can_act_on types Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(ownership): private OwnerId field, ResourceScope serde derives, fix doc comment Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * refactor(tenant): replace SystemScope::db() escape hatch with typed workspace_for_user(), fix stale variable names - Add SystemScope::workspace_for_user() that wraps Workspace::new_with_db - Remove SystemScope::db() which exposed the raw Arc<dyn Database> - Update 3 callers (routine_engine.rs x2, heartbeat.rs x1) to use the new method - Fix stale comment: "admin context" -> "system context" in SystemScope - Rename `admin` bindings to `system` in agent_loop.rs for clarity Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(tenant): rename stale admin binding to system_store in heartbeat.rs * refactor(tenant): TenantScope/TenantCtx carry Identity, add with_identity() constructor and bridge new() - TenantScope: replace `user_id: String` field with `identity: Identity`; add `with_identity()` preferred constructor; keep `new(user_id, db)` as Member-role bridge; add `identity()` accessor; all internal method bodies use `identity.owner_id.as_str()` in place of `&self.user_id` - TenantCtx: replace `user_id: String` field with `identity: Identity`; update constructor signature; add `identity()` accessor; `user_id()` delegates to `identity.owner_id.as_str()`; cost/rate methods updated accordingly - agent_loop: split `tenant_ctx(&str)` into bridge + new `tenant_ctx_with_identity(Identity)` which holds the full body; bridge delegates to avoid duplication Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(db): add V16 tool scope, V17 channel_identities, V18 pairing_requests migrations - PostgreSQL: V16__tool_scope.sql adds scope column to wasm_tools/dynamic_tools - PostgreSQL: V17__channel_identities.sql creates channel identity resolution table - PostgreSQL: V18__pairing_requests.sql creates pairing request table replacing file-based store - libSQL SCHEMA: adds scope column to wasm_tools/dynamic_tools, channel_identities, pairing_requests tables - libSQL INCREMENTAL_MIGRATIONS: versions 17-19 for existing databases - IDEMPOTENT_ADD_COLUMN_MIGRATIONS: handles fresh-install/upgrade dual path for scope columns - Runner updated to check ALL idempotent columns per version before skipping SQL - Test: test_ownership_model_tables_created verifies all new tables/columns exist after migrations Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(db): use correct RFC3339 timestamp default in libSQL, document version sequence offset Replace datetime('now') with strftime('%Y-%m-%dT%H:%M:%fZ', 'now') in the channel_identities and pairing_requests table definitions (both in SCHEMA and INCREMENTAL_MIGRATIONS) to match the project-standard RFC 3339 timestamp format with millisecond precision. Also add a comment clarifying that libSQL incremental migration version numbers are independent from PostgreSQL VN migration numbers. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(ownership): bootstrap_ownership(), migrate_default_owner, V19 FK migration, replace hardcoded 'default' user IDs - Add V19__ownership_fk.sql (programmatic-only, not in auto-migration sweep) - Add `migrate_default_owner` to Database trait + both PgBackend and LibSqlBackend - Add `get_or_create_user` default method to UserStore trait - Add `bootstrap_ownership()` to app.rs, called in init_database() after connect_with_handles - Replace hardcoded "default" owner_id in cli/config.rs, cli/mcp.rs, cli/mod.rs, orchestrator/mod.rs - Add TODO(ownership) comments in llm/session.rs and tools/mcp/client.rs for deferred constructors Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(ownership): atomic get_or_create_user, transactional migrate_default_owner, V19 FK inline constant, fix remaining 'default' user IDs - Delete migrations/V19__ownership_fk.sql so refinery no longer auto-applies FK constraints before bootstrap_ownership runs; add OWNERSHIP_FK_SQL constant with TODO for future programmatic application - Remove racy SELECT+INSERT default in UserStore::get_or_create_user; both PostgreSQL (ON CONFLICT DO NOTHING) and libSQL (INSERT OR IGNORE) now use atomic upserts - Wrap migrate_default_owner in explicit transactions on both backends for atomicity - Make bootstrap_ownership failure fatal (propagate error instead of warn-and-continue) - Fix mcp auth/test --user: change from default_value="default" to Option<String> resolved from configured owner_id - Replace hardcoded "default" user IDs in channels/wasm/setup.rs with config.owner_id - Replace "default" sentinel in OrchestratorState test helper with "<unset>" to make the test-only nature explicit Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(ownership): remove default user_id from create_job(), change sentinel strings to <unset> - Gate ContextManager::create_job() behind #[cfg(test)]; production code must use create_job_for_user() with an explicit user_id to prevent DB rows with user_id = 'default' being silently created on the production write path. - Change the placeholder user_id in McpClient::new(), new_with_name(), and new_with_config() from "default" to "<unset>" so accidental secrets/settings lookups surface immediately rather than silently touching the wrong DB partition. - Same sentinel change for SessionManager::new() and new_async() in session.rs; these are overwritten by attach_store() at startup with the real owner_id. - Update tests that asserted the old "default" sentinel to expect "<unset>", and switch test_list_jobs_tool / test_job_status_tool to create_job_for_user("default") to keep ownership alignment with JobContext::default(). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(db): add ChannelPairingStore sub-trait with resolve_channel_identity, upsert/approve pairing, PostgreSQL + libSQL implementations Adds PairingRequestRecord, ChannelPairingStore trait (5 methods), and generate_pairing_code() to src/db/mod.rs; implements for PgBackend in postgres.rs and LibSqlBackend in libsql/pairing.rs; wires ChannelPairingStore into the Database supertrait bound; all 6 libSQL unit tests pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(db): atomic libSQL approve_pairing with BEGIN IMMEDIATE, add case-insensitive/expired/double-approve tests Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(ownership): add OwnershipCache for zero-DB-read identity resolution on warm path Converts src/ownership.rs to src/ownership/ module directory and adds src/ownership/cache.rs with a write-through in-process cache mapping (channel, external_id) -> Identity. Wired as Arc<OwnershipCache> on AppComponents for Task 8 pairing integration. All 7 cache unit tests pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * test(e2e): add ownership model E2E tests and extend pairing tests for DB-backed store Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): remove unused asyncio import, add fallback assertion in test_pairing_response_structure Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * test(tenant): unit tests for TenantScope::with_identity and AdminScope construction Adds 5 focused unit tests verifying TenantScope::with_identity stores the full Identity (owner_id + role), TenantScope::new creates a Member-role identity, and AdminScope::new returns Some for Admin and None for Member. Uses LibSqlBackend::new_memory() as the test DB stub. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(ownership): recover from RwLock poison instead of expect() in OwnershipCache Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * test(ownership): integration tests for bootstrap, tenant isolation, and ChannelPairingStore Adds tests/ownership_integration.rs covering migrate_default_owner idempotency, TenantScope per-user setting isolation (including Admin role bypass check), and the full ChannelPairingStore lifecycle (upsert, approve, remove, multi-channel isolation). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(test): remove duplicate pairing tests and flaky random-code assertion from integration suite Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(pairing): rewrite PairingStore to DB-backed async with OwnershipCache Replaces the file-based pairing store (~/.ironclaw/*-pairing.json, *-allowFrom.json) with a DB-backed async implementation that delegates to ChannelPairingStore and writes through to OwnershipCache on reads. - PairingStore::new(db, cache) uses the DB; new_noop() for test/no-DB - resolve_identity() cache-first lookup via OwnershipCache - approve(code, owner_id) removes channel arg (DB looks up by code) - All WASM host functions updated: pairing_upsert_request uses block_in_place, pairing-is-allowed renamed to pairing-resolve-identity returning Option<String>, pairing-read-allow-from deprecated (returns empty list) - Signal channel receives PairingStore via new(config, db) constructor - Web gateway pairing handlers read from state.store (DB) directly - extensions.rs derive_activation_status drops PairingStore dependency; derives status from extension.active and owner_binding flag instead - All test call sites updated to use new_noop() Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(pairing): add missing pairing_store field to all GatewayState initializers, fix disk-full post-edit compile Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(channels): remove owner_id from IncomingMessage, user_id is the canonical resolved OwnerId `owner_id` on `IncomingMessage` was always a duplicate of `user_id` — both fields held the same value at every call site. Remove the field and `with_owner_id()` builder, update the four WASM-wrapper and HTTP test assertions to use `user_id`, and drop the redundant struct literal field in the routine_engine test helper. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(channels): remove stale owner_id param from make_message test helper Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * test(e2e): add browser/Playwright tests for ownership model — auth screen, chat UI, owner login Adds five Playwright-based browser tests to the ownership model E2E suite verifying the web UI experience: authenticated owner sees chat input, unauthenticated browser sees auth screen, owner can send a message and receive a response, settings tab renders without errors, and basic page structure is correct after login. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * feat(settings): migrate channel credentials from plaintext settings to encrypted secrets store Moves nearai.session_token from the plaintext DB settings table to the AES-256-GCM encrypted secrets store (key: nearai_session_token). - SessionManager gains an `attach_secrets()` method that wires in the secrets store; `save_session` writes to it when available and `load_session_from_secrets` is called preferentially over settings - `migrate_session_credential()` runs idempotently on each startup in `init_secrets()`, reading the JSON session from settings, writing it to secrets, then deleting the plaintext copy - Wizard's `persist_session_to_db` now writes to secrets first, falling back to plaintext settings only when secrets store is unavailable - Plaintext settings path is preserved as fallback for installs without a secrets store (no master key configured) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(settings): settings fallback only when no secrets store, verify decryption before deleting plaintext Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(ownership): ROLLBACK in libSQL migrate_default_owner, shared OwnershipCache across channels, add dynamic_tools to migration, fix doc comment - libSQL migrate_default_owner: wrap UPDATE loop in async closure + match to emit ROLLBACK on any mid-transaction failure (mirroring approve_pairing pattern) - Both backends: add dynamic_tools to the migrate_default_owner table list so agent-built tools are migrated on first pairing - setup_wasm_channels: accept Arc<OwnershipCache> parameter instead of allocating a fresh cache, share the AppComponents cache - SignalChannel::new: accept Arc<OwnershipCache> parameter and pass it to PairingStore instead of allocating a new cache - PairingStore: fix module-level and struct-level doc comments to accurately describe lazy cache population after approve() Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(web): use can_act_on for authorization in job/routine handlers instead of raw string comparisons Replace 12 raw `user_id != user.user_id` / `user_id == user.user_id` string comparisons in jobs.rs and 4 in routines.rs with calls through the canonical `can_act_on` function from `crate::ownership`, which is the spec-mandated authorization mechanism. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * chore: include remaining modified files in ownership model branch * fix: add pairing_store field to test GatewayState initializers, update PairingStore API calls in integration tests Add missing `pairing_store: None` to all GatewayState struct initializers in test files. Migrate old file-based PairingStore API calls (PairingStore::new(), PairingStore::with_base_dir()) to the new DB-backed API (PairingStore::new_noop()). Rewrite pairing_integration.rs to use LibSqlBackend with the new async DB-backed PairingStore API. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * chore: cargo fmt * fix(pairing): truly no-op PairingStore noop mode, ensure owner user in CLI, fix signal safety comments - PairingStore::upsert_request now returns a dummy record in noop mode instead of erroring, and approve silently succeeds (matching the doc promise of "writes are silently discarded"). - PairingStore::approve now accepts a channel parameter, matching the updated DB trait signature and propagated to all call sites (CLI, web server, tests). - CLI run_pairing_command ensures the owner user row exists before approval to satisfy the FK constraint on channel_identities.owner_id. - Signal channel block_in_place safety comments corrected from "WASM channel callbacks" to "Signal channel message processing". Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(pairing): thread channel through approve_pairing, add created flag, retry on code collision, remove redundant indexes Addresses PR review comments: - approve_pairing validates code belongs to the given channel - PairingRequestRecord.created replaces timing heuristic - upsert retries on UNIQUE violation (up to 3 attempts) - redundant indexes removed (UNIQUE creates implicit index) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(ownership): migrate api_tokens, serialize PG approvals, propagate resolved owner_id Addresses PR review P1/P2 regressions: - api_tokens included in migrate_default_owner (both backends) - PostgreSQL approve_pairing uses FOR UPDATE to prevent concurrent approvals - Signal resolve_sender_identity returns owner_id, set as IncomingMessage.user_id with raw phone number preserved as sender_id for reply routing - Feishu uses resolved owner_id from pairing_resolve_identity in emitted message - PairingStore noop mode logs warning when pairing admission is impossible [skip-regression-check] Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * fix(pr-review): sanitize DB errors in pairing handlers, fix doc comments, add TODO for derive_activation_status - Pairing list/approve handlers no longer leak DB error details to clients - NotFound errors return user-friendly 'Invalid or expired pairing code' message - Module doc in pairing/store.rs corrected (remove -> evict, no insert method) - wit_compat.rs stub comment corrected to match actual Val shape - TODO added for derive_activation_status has_paired approximation * fix(pr-review): propagate libSQL query errors in approve_pairing, round-trip validate session credential migration, fix test doc comment - libSQL approve_pairing: .ok().flatten() replaced with .map_err() to propagate DB errors - migrate_session_credential: round-trip compares decrypted secret against plaintext before deleting - ownership_integration.rs: doc comment corrected to match actual test coverage * fix(pairing): store meta, wrap upserts in transactions, case-insensitive role/channel, log Signal DB errors, use auth role in handlers - Store meta JSONB/TEXT column in pairing_requests (PG migration V18, libSQL schema + incremental migration 19) - Wrap upsert_pairing_request in transactions (PG: client.transaction(), libSQL: BEGIN IMMEDIATE/COMMIT/ROLLBACK) - Case-insensitive role parsing: eq_ignore_ascii_case("admin") in both backends - Case-insensitive channel matching in approve_pairing: LOWER(channel) = LOWER($2) - Log DB errors in Signal resolve_sender_identity instead of silently discarding - Use auth role from UserIdentity in web handlers (jobs.rs, routines.rs) via identity_from_auth helper - Fix variable shadowing: rename `let channel` to `let req_channel` in libsql approve_pairing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): add auth to pairing list, cache eviction on deactivate, runtime assert in Signal, remove default fallback, warn on noop pairing codes Addresses zmanian's review: - #1: pairing_list_handler requires AuthenticatedUser - #2: OwnershipCache.evict_user() evicts all entries for a user on suspension - #3: debug_assert! for multi-thread runtime in Signal block_in_place - #9: Noop PairingStore warns when generating unredeemable codes - #10: cli/mcp.rs default fallback replaced with <unset> * fix(pairing): consistent LOWER() channel matching in resolve_channel_identity, fix wizard doc comment, fix E2E test assertion for ActionResponse convention * fix(pairing): apply LOWER() consistently across all ChannelPairingStore queries (upsert, list_pending, remove) All channel matching now uses LOWER() in both PostgreSQL and libSQL backends: - upsert_pairing_request: WHERE LOWER(channel) = LOWER($1) - list_pending_pairings: WHERE LOWER(channel) = LOWER($1) - remove_channel_identity: WHERE LOWER(channel) = LOWER($1) Previously only resolve_channel_identity and approve_pairing used LOWER(), causing inconsistent matching when channel names differed by case. * fix(pairing): unify code challenge flow and harden web pairing * test: harden pairing review follow-ups * fix: guard wasm pairing callbacks by runtime flavor * fix(pairing): normalize channel keys and serialize pg upserts * chore(web): clean up ownership review follow-ups * Preserve WASM pairing allowlist compatibility --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 994f960 commit 3004583

84 files changed

Lines changed: 4894 additions & 1609 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

channels-src/discord/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,8 +1152,8 @@ fn send_pairing_reply(ctx: &PairingReplyCtx, code: &str) -> Result<(), String> {
11521152

11531153
let mut payload = serde_json::json!({
11541154
"content": format!(
1155-
"To pair with this bot, run: `ironclaw pairing approve discord {}`",
1156-
code
1155+
"Enter this code in IronClaw to pair your discord account: `{}`. CLI fallback: `ironclaw pairing approve discord {}`",
1156+
code, code
11571157
)
11581158
});
11591159

channels-src/feishu/src/lib.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -517,16 +517,23 @@ fn handle_message_event(event_data: &serde_json::Value) {
517517
// DM pairing check for p2p chats.
518518
let chat_type = msg_event.message.chat_type.as_deref().unwrap_or("unknown");
519519

520+
// Resolved user_id for the emitted message. Defaults to sender_id but
521+
// is overwritten with the owner_id when the sender is paired, ensuring
522+
// the message is scoped to the correct owner/tenant.
523+
let mut user_id = sender_id.to_string();
524+
520525
if chat_type == "p2p" {
521526
let dm_policy =
522527
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
523528

524529
if dm_policy == "pairing" {
525-
let sender_name = sender_id.to_string();
526-
match channel_host::pairing_is_allowed("feishu", sender_id, Some(&sender_name)) {
527-
Ok(true) => {}
528-
Ok(false) => {
529-
// Upsert a pairing request.
530+
match channel_host::pairing_resolve_identity("feishu", sender_id) {
531+
Ok(Some(owner_id)) => {
532+
// Sender is paired; scope message to owner.
533+
user_id = owner_id;
534+
}
535+
Ok(None) => {
536+
// Unknown sender — upsert a pairing request.
530537
let meta = serde_json::json!({
531538
"sender_id": sender_id,
532539
"chat_id": msg_event.message.chat_id,
@@ -586,7 +593,7 @@ fn handle_message_event(event_data: &serde_json::Value) {
586593

587594
// Emit message to the agent.
588595
channel_host::emit_message(&EmittedMessage {
589-
user_id: sender_id.to_string(),
596+
user_id,
590597
user_name: None,
591598
content: text,
592599
thread_id,

channels-src/slack/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,8 +757,8 @@ fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> {
757757
let payload = serde_json::json!({
758758
"channel": channel_id,
759759
"text": format!(
760-
"To pair with this bot, run: `ironclaw pairing approve slack {}`",
761-
code
760+
"Enter this code in IronClaw to pair your slack account: `{}`. CLI fallback: `ironclaw pairing approve slack {}`",
761+
code, code
762762
),
763763
});
764764

channels-src/telegram/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,8 +1634,8 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
16341634
send_message(
16351635
chat_id,
16361636
&format!(
1637-
"To pair with this bot, run: `ironclaw pairing approve telegram {}`",
1638-
code
1637+
"Enter this code in IronClaw to pair your telegram account: `{}`. CLI fallback: `ironclaw pairing approve telegram {}`",
1638+
code, code
16391639
),
16401640
None,
16411641
Some("Markdown"),

channels-src/whatsapp/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -910,8 +910,8 @@ fn send_pairing_reply(
910910
"text": {
911911
"preview_url": false,
912912
"body": format!(
913-
"To pair with this bot, run: ironclaw pairing approve whatsapp {}",
914-
code
913+
"Enter this code in IronClaw to pair your whatsapp account: {}. CLI fallback: ironclaw pairing approve whatsapp {}",
914+
code, code
915915
)
916916
}
917917
});

docs/TELEGRAM_SETUP.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ When an unknown user DMs your bot, they receive a pairing code. You must approve
5757
### Flow
5858

5959
1. Unknown user sends a message to your bot
60-
2. Bot replies: `To pair with this bot, run: ironclaw pairing approve telegram ABC12345`
61-
3. You run: `ironclaw pairing approve telegram ABC12345`
62-
4. User is added to the allow list; future messages are delivered
60+
2. Bot replies with a one-time pairing code
61+
3. The user enters that code in IronClaw's channel settings to claim the Telegram account
62+
4. CLI fallback: `ironclaw pairing approve telegram ABC12345`
63+
5. The user's Telegram identity is linked to the owner; future messages resolve to that owner and are delivered
6364

6465
### Commands
6566

migrations/V18__tool_scope.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Add scope column to tool tables for future admin-promoted global tools.
2+
-- All existing rows default to 'user'. Nothing sets 'global' yet.
3+
ALTER TABLE wasm_tools ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'user';
4+
ALTER TABLE dynamic_tools ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'user';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- Maps a channel-specific external identity to an IronClaw owner.
2+
-- This is how inbound messages are resolved to the right user's resources.
3+
CREATE TABLE channel_identities (
4+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5+
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+
channel TEXT NOT NULL CHECK (channel = LOWER(channel)),
7+
external_id TEXT NOT NULL,
8+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9+
UNIQUE (channel, external_id)
10+
);
11+
12+
-- UNIQUE (channel, external_id) already creates an implicit index on (channel, external_id).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Replaces file-based pairing store (~/.ironclaw/{channel}-pairing.json).
2+
-- A pending request has owner_id = NULL until approved.
3+
CREATE TABLE pairing_requests (
4+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5+
channel TEXT NOT NULL CHECK (channel = LOWER(channel)),
6+
external_id TEXT NOT NULL,
7+
code TEXT NOT NULL UNIQUE,
8+
owner_id TEXT REFERENCES users(id) ON DELETE CASCADE,
9+
meta JSONB,
10+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
11+
expires_at TIMESTAMPTZ NOT NULL,
12+
approved_at TIMESTAMPTZ
13+
);
14+
15+
-- UNIQUE on code already creates an implicit index; no separate idx_pairing_requests_code needed.
16+
CREATE INDEX idx_pairing_requests_channel ON pairing_requests (channel, external_id);

src/agent/agent_loop.rs

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ impl Agent {
247247
store: deps
248248
.store
249249
.as_ref()
250-
.map(|db| crate::tenant::AdminScope::new(Arc::clone(db))),
250+
.map(|db| crate::tenant::SystemScope::new(Arc::clone(db))),
251251
hooks: deps.hooks.clone(),
252252
},
253253
);
@@ -363,13 +363,28 @@ impl Agent {
363363
/// [`TenantCtx`] provides a [`TenantScope`] that auto-binds `user_id` on
364364
/// every database operation and a per-user rate limiter.
365365
pub(super) async fn tenant_ctx(&self, user_id: &str) -> crate::tenant::TenantCtx {
366+
use crate::ownership::{Identity, OwnerId, UserRole};
367+
// Bridge: creates Member identity from raw string.
368+
// Will be replaced by OwnershipCache lookup in Task 9.
369+
let identity = Identity::new(OwnerId::from(user_id), UserRole::Member);
370+
self.tenant_ctx_with_identity(identity).await
371+
}
372+
373+
/// Build a tenant-scoped execution context from a resolved `Identity`.
374+
///
375+
/// Preferred over [`tenant_ctx`](Self::tenant_ctx) once the call site has a
376+
/// full `Identity` available.
377+
pub(super) async fn tenant_ctx_with_identity(
378+
&self,
379+
identity: crate::ownership::Identity,
380+
) -> crate::tenant::TenantCtx {
381+
let user_id = identity.owner_id.as_str();
366382
let rate = self.deps.tenant_rates.get_or_create(user_id).await;
367383

368-
let store = self
369-
.deps
370-
.store
371-
.as_ref()
372-
.map(|db| crate::tenant::TenantScope::new(user_id, Arc::clone(db)));
384+
let store =
385+
self.deps.store.as_ref().map(|db| {
386+
crate::tenant::TenantScope::with_identity(identity.clone(), Arc::clone(db))
387+
});
373388

374389
// Reuse the owner workspace if user matches, otherwise create per-user.
375390
// Per-user workspaces are seeded on first creation so they get identity
@@ -394,23 +409,23 @@ impl Agent {
394409
};
395410

396411
crate::tenant::TenantCtx::new(
397-
user_id,
412+
identity,
398413
store,
399414
workspace,
400415
Arc::clone(&self.deps.cost_guard),
401416
rate,
402417
)
403418
}
404419

405-
/// Get an admin-scoped database accessor for cross-tenant operations.
420+
/// Get a system-scoped database accessor for cross-tenant operations.
406421
///
407422
/// Only for system-level components (heartbeat, routine engine, self-repair,
408423
/// scheduler). Handler code should use [`tenant_ctx()`](Self::tenant_ctx) instead.
409-
pub(super) fn admin_store(&self) -> Option<crate::tenant::AdminScope> {
424+
pub(super) fn system_store(&self) -> Option<crate::tenant::SystemScope> {
410425
self.deps
411426
.store
412427
.as_ref()
413-
.map(|db| crate::tenant::AdminScope::new(Arc::clone(db)))
428+
.map(|db| crate::tenant::SystemScope::new(Arc::clone(db)))
414429
}
415430

416431
pub(super) fn skill_registry(&self) -> Option<&Arc<std::sync::RwLock<SkillRegistry>>> {
@@ -507,8 +522,8 @@ impl Agent {
507522
self.config.stuck_threshold,
508523
self.config.max_repair_attempts,
509524
);
510-
if let Some(admin) = self.admin_store() {
511-
self_repair = self_repair.with_store(admin);
525+
if let Some(system) = self.system_store() {
526+
self_repair = self_repair.with_store(system);
512527
}
513528
if let Some(ref builder) = self.deps.builder {
514529
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
@@ -704,13 +719,13 @@ impl Agent {
704719
.unwrap_or_default();
705720

706721
if config.multi_tenant {
707-
if let Some(admin) = self.admin_store() {
722+
if let Some(system) = self.system_store() {
708723
Some(spawn_multi_user_heartbeat(
709724
config,
710725
hygiene,
711726
self.cheap_llm().clone(),
712727
Some(notify_tx),
713-
admin,
728+
system,
714729
))
715730
} else {
716731
tracing::warn!("Multi-tenant heartbeat requires a database store");
@@ -723,7 +738,7 @@ impl Agent {
723738
workspace.clone(),
724739
self.cheap_llm().clone(),
725740
Some(notify_tx),
726-
self.admin_store(),
741+
self.system_store(),
727742
))
728743
}
729744
} else {
@@ -747,7 +762,7 @@ impl Agent {
747762

748763
let engine = Arc::new(RoutineEngine::new(
749764
rt_config.clone(),
750-
crate::tenant::AdminScope::new(Arc::clone(store)),
765+
crate::tenant::SystemScope::new(Arc::clone(store)),
751766
self.llm().clone(),
752767
Arc::clone(workspace),
753768
notify_tx,

0 commit comments

Comments
 (0)