From 2cfb9ba653ced39d6fbd553ea962df08bade2349 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Sat, 5 Sep 2026 16:06:57 -0400 Subject: [PATCH 1/3] [claude] feat(db): machine homes for agents (AGENT-HOMES-001 PR-3) Records the machine an agent actually runs on as first-class relay data, so PR-4 can grant cross-machine SSH between named homes. Modeling choice (the open item flagged for review): columns on users, not a new table. Agents are already users; a dedicated table would create a second identity space to keep in sync, and the plan calls for no second identity space. - 0047_agent_machine_homes.sql: additive nullable machine_id/label/runtime - one home per machine per community via partial unique index - label/runtime require machine_id (no orphan metadata) - community-scoped: same machine_id may exist in another community Tests: 14/14 store::user postgres tests, 13/13 migration tests (incl. the scoped_primary_key + tenant-scoping lints). --- crates/buzz-db/src/runtime/migration.rs | 5 +- crates/buzz-db/src/store/user.rs | 315 ++++++++++++++++++++++++ migrations/0047_agent_machine_homes.sql | 59 +++++ 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 migrations/0047_agent_machine_homes.sql diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index fbc8c79e98b..5b71bb22156 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -703,8 +703,9 @@ mod postgres_tests { migrations.sort_by_key(|migration| migration.version); // upstream carries 44 (0032-0034 and 0040 adopted from our PRs); - // fork adds 0046_task_system (PR #6425 pending upstream). - assert_eq!(migrations.len(), 45); + // fork adds 0046_task_system (PR #6425 pending upstream) and + // 0047_agent_machine_homes (AGENT-HOMES-001 PR-3). + assert_eq!(migrations.len(), 46); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 759e916e4b4..f65ba501ee8 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -360,6 +360,129 @@ async fn set_agent_owner_with_operation( Ok(true) } +/// The machine an agent calls home: stable host id, human label, and runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineHome { + /// Stable host identity (the desktop's device id). + pub machine_id: String, + /// Human-facing, renameable label. + pub machine_label: Option, + /// Runtime serving this home (`"hermes"`, `"openclaw"`, `"claude-code"`, ...). + pub machine_runtime: Option, +} + +/// Register (or re-register) `agent_pubkey` as the home agent for a machine. +/// +/// One home per machine per community is a database invariant +/// (`idx_users_one_home_per_machine`), not a check performed here: a +/// read-then-write would race two concurrent registrations onto the same host. +/// A conflicting claim therefore surfaces as a unique violation, which is +/// translated to [`DbError::AccessDenied`] so callers get an actionable +/// message instead of a raw SQLSTATE. +/// +/// Returns `Err(DbError::NotFound)` if the agent pubkey has no `users` row. +pub async fn set_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], + home: &MachineHome, +) -> Result<()> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = sqlx::query( + r#"UPDATE users SET machine_id = $1, machine_label = $2, machine_runtime = $3, updated_at = NOW() WHERE community_id = $4 AND pubkey = $5"#, + ) + .bind(&home.machine_id) + .bind(home.machine_label.as_deref()) + .bind(home.machine_runtime.as_deref()) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .execute(&mut *connection) + .await; + + match result { + Ok(done) if done.rows_affected() == 0 => Err(crate::error::DbError::NotFound( + "agent pubkey not found in users table".into(), + )), + Ok(_) => Ok(()), + // 23505 = unique_violation: another agent already homes this machine. + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { + Err(crate::error::DbError::AccessDenied(format!( + "machine {} already has a home agent in this community", + home.machine_id + ))) + } + Err(e) => Err(e.into()), + } +} + +/// Clear an agent's machine home, freeing the machine for another agent. +/// +/// Returns `true` if a home was cleared, `false` if the row exists but had no +/// home. All three columns drop together: the migration's +/// `chk_users_machine_fields_require_machine_id` makes a label or runtime +/// without a `machine_id` unrepresentable. +pub async fn clear_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], +) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = sqlx::query( + r#"UPDATE users SET machine_id = NULL, machine_label = NULL, machine_runtime = NULL, updated_at = NOW() WHERE community_id = $1 AND pubkey = $2 AND machine_id IS NOT NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .execute(&mut *connection) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Look up an agent's machine home. `None` when the user is absent or unhomed. +pub async fn get_machine_home( + pool: &PgPool, + community_id: CommunityId, + agent_pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#"SELECT machine_id, machine_label, machine_runtime FROM users WHERE community_id = $1 AND pubkey = $2 AND machine_id IS NOT NULL"#, + ) + .bind(community_id.as_uuid()) + .bind(agent_pubkey) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| MachineHome { + machine_id: r.get("machine_id"), + machine_label: r.get("machine_label"), + machine_runtime: r.get("machine_runtime"), + })) +} + +/// Resolve the agent that homes `machine_id`, if any. +/// +/// This is the lookup that makes a machine home addressable: given a host, find +/// the pubkey that answers for it. +pub async fn get_agent_for_machine( + pool: &PgPool, + community_id: CommunityId, + machine_id: &str, +) -> Result>> { + let row = + sqlx::query(r#"SELECT pubkey FROM users WHERE community_id = $1 AND machine_id = $2"#) + .bind(community_id.as_uuid()) + .bind(machine_id) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.get::, _>("pubkey"))) +} + /// Get the channel_add_policy and agent_owner_pubkey for a user. /// Returns None if the pubkey is not in the users table. /// Returns Some((policy_str, owner_bytes_or_none)) if found. @@ -861,4 +984,196 @@ mod postgres_tests { assert_eq!(result.0, "owner_only"); assert!(result.1.is_none(), "owner should be None when never set"); } + + /// A registered machine home round-trips, and the machine resolves back to + /// the agent that homes it. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_machine_home_round_trip() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent = random_pubkey(); + ensure_user(&db.pool, community, &agent) + .await + .expect("ensure agent"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: Some("Winnie".to_owned()), + machine_runtime: Some("openclaw".to_owned()), + }; + set_machine_home(&db.pool, community, &agent, &home) + .await + .expect("set home"); + + let got = get_machine_home(&db.pool, community, &agent) + .await + .expect("get home") + .expect("home should be Some"); + assert_eq!(got, home); + + let resolved = get_agent_for_machine(&db.pool, community, &home.machine_id) + .await + .expect("resolve machine") + .expect("machine should resolve"); + assert_eq!(resolved, agent, "machine must resolve to its home agent"); + } + + /// The core PR-3 invariant: one home agent per machine, per community. The + /// second claim must be rejected rather than silently stealing the host. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_second_agent_cannot_claim_same_machine() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let first = random_pubkey(); + let second = random_pubkey(); + ensure_user(&db.pool, community, &first).await.expect("a"); + ensure_user(&db.pool, community, &second).await.expect("b"); + + let machine_id = format!("machine-{}", uuid::Uuid::new_v4()); + let home = MachineHome { + machine_id: machine_id.clone(), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community, &first, &home) + .await + .expect("first claim wins"); + + let conflict = set_machine_home(&db.pool, community, &second, &home).await; + assert!( + matches!(conflict, Err(crate::error::DbError::AccessDenied(_))), + "second claim on the same machine must be denied, got {conflict:?}" + ); + + // The original home is untouched by the failed claim. + let still = get_agent_for_machine(&db.pool, community, &machine_id) + .await + .expect("resolve") + .expect("still homed"); + assert_eq!(still, first); + } + + /// Admission confinement: the same machine id in a different community is a + /// different machine, so the unique index must not collide across tenants. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_same_machine_id_allowed_in_other_community() { + let db = setup_db().await; + let community_a = make_community(&db.pool).await; + let community_b = make_community(&db.pool).await; + let agent_a = random_pubkey(); + let agent_b = random_pubkey(); + ensure_user(&db.pool, community_a, &agent_a) + .await + .expect("a"); + ensure_user(&db.pool, community_b, &agent_b) + .await + .expect("b"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community_a, &agent_a, &home) + .await + .expect("community A claim"); + set_machine_home(&db.pool, community_b, &agent_b, &home) + .await + .expect("community B must be independent of A"); + } + + /// Clearing a home frees the machine for another agent to claim. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_clear_machine_home_frees_the_machine() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let first = random_pubkey(); + let second = random_pubkey(); + ensure_user(&db.pool, community, &first).await.expect("a"); + ensure_user(&db.pool, community, &second).await.expect("b"); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + set_machine_home(&db.pool, community, &first, &home) + .await + .expect("first claim"); + + assert!( + clear_machine_home(&db.pool, community, &first) + .await + .expect("clear"), + "clearing an existing home reports true" + ); + assert!( + get_machine_home(&db.pool, community, &first) + .await + .expect("get") + .is_none(), + "home is gone after clear" + ); + assert!( + !clear_machine_home(&db.pool, community, &first) + .await + .expect("clear again"), + "clearing an unhomed agent reports false" + ); + + set_machine_home(&db.pool, community, &second, &home) + .await + .expect("machine is free for the next agent"); + } + + /// Registering a home for a pubkey with no users row is an error, not a + /// silent no-op that would leave the machine unhomed. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_set_machine_home_nonexistent_agent() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let ghost = random_pubkey(); + + let home = MachineHome { + machine_id: format!("machine-{}", uuid::Uuid::new_v4()), + machine_label: None, + machine_runtime: None, + }; + let result = set_machine_home(&db.pool, community, &ghost, &home).await; + assert!( + matches!(result, Err(crate::error::DbError::NotFound(_))), + "unknown agent must not be homed, got {result:?}" + ); + } + + /// A label or runtime with no machine_id is unaddressable, and the database + /// must reject it rather than storing a half-registered home. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_label_without_machine_id_is_rejected() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let agent = random_pubkey(); + ensure_user(&db.pool, community, &agent) + .await + .expect("ensure agent"); + + let result = sqlx::query( + "UPDATE users SET machine_label = $1 WHERE community_id = $2 AND pubkey = $3", + ) + .bind("orphan-label") + .bind(community.as_uuid()) + .bind(&agent) + .execute(&db.pool) + .await; + assert!( + result.is_err(), + "a label with no machine_id must violate chk_users_machine_fields_require_machine_id" + ); + } } diff --git a/migrations/0047_agent_machine_homes.sql b/migrations/0047_agent_machine_homes.sql new file mode 100644 index 00000000000..f2abcbce413 --- /dev/null +++ b/migrations/0047_agent_machine_homes.sql @@ -0,0 +1,59 @@ +-- Machine homes: the machine an agent actually runs on, as first-class relay data. +-- +-- Modeling choice (the open item PR-3 was asked to settle at review): these are +-- columns on `users`, NOT a new `agent_machine_homes` table. 0046 states the rule +-- this follows -- "a task creator/assignee is a `users` row, never a separate +-- agent table. Agents in Buzz *are* users" -- and warns that a dedicated id +-- "would invent a second identity space that nothing else in the schema uses." +-- A side table keyed by `(community_id, pubkey)` would hold exactly one row per +-- agent and would be joined on every read, so it buys no cardinality the user +-- row cannot express, while adding precisely the second identity space 0046 +-- rejects. `users.agent_owner_pubkey` (NIP-OA) already set this precedent: +-- agent-shaped facts live on the agent's own user row. +-- +-- `machine_id` is the stable host identity (the desktop's device id); the label +-- is human-facing and renameable. `machine_runtime` is unconstrained TEXT for +-- the reason 0031 gives for `workflow_runs.error_code` and 0046 repeats for +-- `tasks.source`: a new runtime (openclaw, hermes, claude-code, codex) must be +-- addable across a rolling upgrade without a schema migration. +-- +-- Every constraint leads with `community_id`, as the migration lint +-- (`scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id`) +-- requires, so one community's machine registration is invisible to another. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE users + ADD COLUMN machine_id VARCHAR(255), + ADD COLUMN machine_label VARCHAR(255), + ADD COLUMN machine_runtime TEXT; + +-- One home agent per machine, per community. This is the "one-home-per-machine" +-- invariant the agent-homes program is built on: two agents claiming the same +-- host is the exact ambiguity that makes a task assignee meaningless. Enforced +-- as a partial unique index so the (overwhelming) majority of users, who carry +-- no machine_id at all, are entirely unconstrained. +CREATE UNIQUE INDEX idx_users_one_home_per_machine + ON users (community_id, machine_id) + WHERE machine_id IS NOT NULL; + +-- A machine home is meaningless without the machine it names, and a bare label +-- or runtime with no `machine_id` is unaddressable -- it could never be resolved +-- to a host. Rejecting that at the database keeps a half-registered home +-- unrepresentable rather than merely discouraged. +ALTER TABLE users + ADD CONSTRAINT chk_users_machine_fields_require_machine_id + CHECK (machine_id IS NOT NULL + OR (machine_label IS NULL AND machine_runtime IS NULL)); + +-- Blank/whitespace ids and labels are the other way a home becomes +-- unaddressable, and TEXT columns accept them silently. +ALTER TABLE users + ADD CONSTRAINT chk_users_machine_id_not_blank + CHECK (machine_id IS NULL OR length(btrim(machine_id)) > 0), + ADD CONSTRAINT chk_users_machine_label_not_blank + CHECK (machine_label IS NULL OR length(btrim(machine_label)) > 0), + ADD CONSTRAINT chk_users_machine_runtime_not_blank + CHECK (machine_runtime IS NULL OR length(btrim(machine_runtime)) > 0); + +-- `users` already carries the universal community write fence from 0001; adding +-- columns does not detach it, so no re-attach is needed here. From 490e7c72cc0d4ecb44682a634e91a943de05e92a Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Fri, 11 Sep 2026 20:06:42 -0400 Subject: [PATCH 2/3] [lenny] fix(db): mirror 0047 machine-home DDL into schema/schema.sql CI builds its test database from schema/schema.sql, the declarative snapshot, not by replaying migrations/. 0047 added the machine-home columns only to the migration, so all 5 store::user::postgres_tests::*machine* tests failed CI with 'column machine_id of relation users does not exist' while passing locally. Mirrors the three columns, four CHECK constraints, and the partial unique index into the users table in schema.sql. Verified by building two databases from scratch and diffing catalogs: migration-replay vs schema.sql are IDENTICAL on users columns, machine constraints, and indexes. store::user::postgres_tests: 14 passed, 0 failed against a schema.sql-built DB, covering all 5 that CI reported red. --- schema/schema.sql | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/schema/schema.sql b/schema/schema.sql index a741e1cfb6d..2a27512f5c0 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -180,8 +180,29 @@ CREATE TABLE users ( metadata_event_id BYTEA, agent_owner_pubkey BYTEA, channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone', + -- Machine home (0047): the machine an agent actually runs on. Columns on + -- `users`, not a side table -- agents in Buzz *are* users (0046), and + -- `agent_owner_pubkey` already set the precedent that agent-shaped facts + -- live on the agent's own row. `machine_id` is the stable host identity; + -- `machine_runtime` is unconstrained TEXT so a new runtime is addable + -- across a rolling upgrade without a schema migration. + machine_id VARCHAR(255), + machine_label VARCHAR(255), + machine_runtime TEXT, PRIMARY KEY (community_id, pubkey), CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32), + -- A machine home is meaningless without the machine it names: a bare label + -- or runtime with no `machine_id` is unaddressable. + CONSTRAINT chk_users_machine_fields_require_machine_id + CHECK (machine_id IS NOT NULL + OR (machine_label IS NULL AND machine_runtime IS NULL)), + -- Blank/whitespace values are the other way a home becomes unaddressable. + CONSTRAINT chk_users_machine_id_not_blank + CHECK (machine_id IS NULL OR length(btrim(machine_id)) > 0), + CONSTRAINT chk_users_machine_label_not_blank + CHECK (machine_label IS NULL OR length(btrim(machine_label)) > 0), + CONSTRAINT chk_users_machine_runtime_not_blank + CHECK (machine_runtime IS NULL OR length(btrim(machine_runtime)) > 0), -- agent owner is a user in the SAME community. FOREIGN KEY (community_id, agent_owner_pubkey) REFERENCES users (community_id, pubkey) ON DELETE SET NULL @@ -193,6 +214,14 @@ CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; +-- One home agent per machine, per community (0047). Two agents claiming the +-- same host is the exact ambiguity that makes a task assignee meaningless. +-- Partial, so the majority of users -- who carry no machine_id -- are +-- entirely unconstrained. +CREATE UNIQUE INDEX idx_users_one_home_per_machine + ON users (community_id, machine_id) + WHERE machine_id IS NOT NULL; + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the From f4c915005f549886cdbf68adf47e8603f2136511 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Fri, 11 Sep 2026 20:52:03 -0400 Subject: [PATCH 3/3] [lenny] fix(db): restore multi-column CHECKs pgschema drops on bootstrap CI builds its database with 'pgschema apply --file schema/schema.sql', and pgschema does not reproduce multi-column CHECK constraints. Declaring them in schema.sql is not enough - they are silently absent from the live catalog. test_label_without_machine_id_is_rejected caught this: it expects a bare machine_label with no machine_id to be rejected, which passed on a migration-replayed DB and failed in CI. Restores two constraints in the existing reconcile script, using its idempotent DROP/ADD + verify-or-RAISE pattern: - users.chk_users_machine_fields_require_machine_id (0047) - without it a pgschema DB accepts an unaddressable agent home. - tasks.chk_tasks_done_at_matches_status (0046, already on trunk) - same gap, no test covered it; a task could be done with no done_at. Verified against a real pgschema bootstrap: all chk_ constraints are now IDENTICAL between migration-replay and pgschema+reconcile, and store::user::postgres_tests is 14 passed / 0 failed on a CI-identical DB. --- scripts/reconcile-schema-after-pgschema.sql | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/scripts/reconcile-schema-after-pgschema.sql b/scripts/reconcile-schema-after-pgschema.sql index 7c3a7be871a..420159ac290 100644 --- a/scripts/reconcile-schema-after-pgschema.sql +++ b/scripts/reconcile-schema-after-pgschema.sql @@ -205,6 +205,47 @@ ALTER TABLE replica_heartbeat SET (vacuum_truncate = false); INSERT INTO replica_heartbeat (id) VALUES (1) ON CONFLICT (id) DO NOTHING; +-- pgschema does not reproduce multi-column CHECK constraints, so it drops +-- chk_users_machine_fields_require_machine_id (it keeps the three +-- single-column machine CHECKs and the partial unique index). Without this, +-- a pgschema-bootstrapped database accepts a machine_label/machine_runtime +-- with no machine_id -- an unaddressable agent home -- while a +-- migration-managed database rejects it. +ALTER TABLE users DROP CONSTRAINT IF EXISTS chk_users_machine_fields_require_machine_id; +ALTER TABLE users + ADD CONSTRAINT chk_users_machine_fields_require_machine_id + CHECK (machine_id IS NOT NULL + OR (machine_label IS NULL AND machine_runtime IS NULL)); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'users'::regclass + AND conname = 'chk_users_machine_fields_require_machine_id' + ) THEN + RAISE EXCEPTION 'users must enforce machine-home field coherence after pgschema apply'; + END IF; +END $$; + +-- Same pgschema multi-column CHECK gap on the task system (0046): a task could +-- be marked done with no done_at, or carry a done_at while not done. +ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_done_at_matches_status; +ALTER TABLE tasks + ADD CONSTRAINT chk_tasks_done_at_matches_status + CHECK ((status = 'done') = (done_at IS NOT NULL)); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'tasks'::regclass + AND conname = 'chk_tasks_done_at_matches_status' + ) THEN + RAISE EXCEPTION 'tasks must enforce done_at/status coherence after pgschema apply'; + END IF; +END $$; + DO $$ BEGIN IF NOT EXISTS (