BUZZ-301: Add user deletion request intake (#8496)

## Summary

Add the first testable piece of user deletion: submit a synthetic
request, list requests, and inspect one by UUID. Operators can verify
that intake reaches PostgreSQL before we add inventory, approval, and
execution.

Add one operator-global `entity_deletion_requests` table with an
additive migration and matching desired-state schema. Identical
submissions return the same row. Conflicting submissions fail without
changing it. Only `demo:` targets are accepted, and requests stay
`submitted`.

Keep the commands in `buzz-deletion`, reached through a thin `buzz-admin
user-deletions` adapter. Reuse the existing database connection setup.
This change adds no worker, stage history, community gates, or deletion
operations.

### Rollout plan

This is an expand-only schema change plus an operator CLI. Existing
relay code does not use the new request table. No backfill or real
deletion is part of this release.

1. **Order the PRs.** Land #8496 first, using 0068; main now owns 0067
for channel join roles. Rework #8489 to build on this table with the
next available migration number. Renumbering its existing CREATE TABLE
migration alone is insufficient; both PRs currently create the same
request table.
2. **Apply on staging.** For deployments that manage schema separately,
apply the reviewed additive migration before using the new CLI. Where
auto-migration is enabled, the new relay binary applies it at startup.
Keep existing tables and records unchanged.
3. **Verify on staging.** Use the new `buzz-admin` binary with
`DATABASE_URL` pointing to staging. Submit a unique synthetic label,
list requests, and inspect the returned UUID:

   ```bash
buzz-admin user-deletions submit --target demo:staging-intake-1
--requested-by <operator>
   buzz-admin user-deletions list --limit 5
   buzz-admin user-deletions inspect <returned-uuid>
   ```

Verify one `entity_deletion_requests` row with status `submitted`.
Repeat the identical submission and verify the same UUID and timestamps.
Use a new label if this label was used by an earlier staging test.
4. **Apply beyond staging.** After verification, use the deployment's
reviewed schema/release workflow to add the table in the remaining
environments. Requests run only through the operator CLI; this release
starts no deletion worker. No feature flag is needed for this synthetic
intake: the database accepts only `demo:` targets, and real deletion is
absent.
5. **Rollback.** Keep the additive table and saved requests; stop using
the new commands and roll back the binary if needed. After 0068 is
applied, an older binary with auto-migration enabled can fail at startup
with sqlx `VersionMissing`. A binary rollback therefore requires
migration execution to be disabled on the older relay
(`BUZZ_AUTO_MIGRATE=false`, including the corresponding deployment
configuration).

### Related work

This is a smaller first slice of the full-workflow draft
[#8489](https://github.com/block/buzz/pull/8489), which is preserved for
later extraction. The two PRs overlap and should not both be merged as
written.

### Testing

The author submitted `demo:rarora-manual-1` to a separate local
PostgreSQL 17 database. Repeating the same submission returned its
original UUID and timestamps. List, inspect, and a direct SQL query all
returned the same saved request.

<details>
<summary>Actual command inputs and outputs</summary>

Commands ran from the new worktree with `DATABASE_URL` set to the local
`buzz_user_deletion_submit` database. Outputs below are stdout;
connection diagnostics on stderr are omitted.

**Submit again — returns the existing request without creating a
duplicate**

```bash
./target/debug/buzz-admin user-deletions submit \
  --target demo:rarora-manual-1 --requested-by rarora
```

```json
{
  "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
  "entity_type": "user",
  "target": "demo:rarora-manual-1",
  "status": "submitted",
  "requested_by": "rarora",
  "reason": null,
  "created_at": "2026-10-20T19:57:44.516698Z",
  "updated_at": "2026-10-20T19:57:44.516698Z"
}
```

**List saved requests**

```bash
./target/debug/buzz-admin user-deletions list --limit 5
```

```json
[
  {
    "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
    "entity_type": "user",
    "target": "demo:rarora-manual-1",
    "status": "submitted",
    "requested_by": "rarora",
    "reason": null,
    "created_at": "2026-10-20T19:57:44.516698Z",
    "updated_at": "2026-10-20T19:57:44.516698Z"
  }
]
```

**Inspect one request**

```bash
./target/debug/buzz-admin user-deletions inspect 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89
```

```json
{
  "id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
  "entity_type": "user",
  "target": "demo:rarora-manual-1",
  "status": "submitted",
  "requested_by": "rarora",
  "reason": null,
  "created_at": "2026-10-20T19:57:44.516698Z",
  "updated_at": "2026-10-20T19:57:44.516698Z"
}
```

**Read the table directly**

```bash
docker exec buzz-user-deletion-dev-20261020 \
  psql -X -U buzz -d buzz_user_deletion_submit -c \
  "SELECT id, entity_type, target, status, requested_by
   FROM entity_deletion_requests ORDER BY created_at DESC;"
```

```text
                  id                  | entity_type |        target        |  status   | requested_by 
--------------------------------------+-------------+----------------------+-----------+--------------
 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89 | user        | demo:rarora-manual-1 | submitted | rarora
(1 row)
```

</details>

A separate CLI smoke test also verified that a conflicting operator and
a non-demo target fail without changing the saved request. The existing
community-deletion `list --limit 1` command passed against the smoke
database.

See [the intake
walkthrough](729233c726/docs/operator-user-deletion-intake.md)
for setup, commands, expected fields, and the SQL query.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
This commit is contained in:
ravarora2
2026-10-22 10:49:27 -04:00
committed by GitHub
parent a2d28eb6c4
commit 2d289f720d
16 changed files with 584 additions and 14 deletions

View File

@@ -454,9 +454,9 @@ test-unit:
-E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)'
# buzz-admin: storage-snapshot worker, the community archive command
# parser, validation, and propagation-evidence tests, and the partition
# audit outcome contract, and the NIP-FI binding command's policy loading and backfill report.
# audit outcome contract, NIP-FI policy/backfill, and user-deletion intake.
cargo nextest run -p buzz-admin \
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/)'
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)'
# Multi-tenant conformance gate (buzz-conformance): the independent
# replay checker + golden fixtures. No infra — pure in-process trace
# replay — so it belongs in the unit job. Run all targets (lib + the

View File

@@ -22,6 +22,7 @@
mod communities;
mod deletions;
mod nip_fi;
mod user_deletions;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -126,6 +127,11 @@ enum Command {
#[command(subcommand)]
command: deletions::DeletionsCommand,
},
/// Submit and inspect synthetic user-deletion requests.
UserDeletions {
#[command(subcommand)]
command: user_deletions::UserDeletionsCommand,
},
/// Emit missing kind:39000/39001/39002 channel discovery events, or
/// republish only a targeted channel's kind:39002 roster.
///
@@ -203,6 +209,7 @@ async fn run(cli: Cli) -> Result<i32> {
} => cmd_list_product_feedback(limit).await,
Command::Communities { command } => communities::run(command).await,
Command::Deletions { command } => deletions::run(command).await,
Command::UserDeletions { command } => user_deletions::run(command).await,
Command::NipFi { command } => nip_fi::run(command).await,
Command::ReconcileChannels { channel, relay_key } => {
reconcile_channels(channel, relay_key).await?;

View File

@@ -0,0 +1,40 @@
//! Thin `buzz-admin user-deletions` adapter.
pub use buzz_deletion::user::Command as UserDeletionsCommand;
/// Delegate request intake to the shared deletion crate.
pub async fn run(command: UserDeletionsCommand) -> anyhow::Result<i32> {
buzz_deletion::user::run(command).await
}
#[cfg(test)]
mod tests {
use clap::Parser;
#[test]
fn user_intake_commands_are_registered_without_execution() {
for args in [
vec![
"buzz-admin",
"user-deletions",
"submit",
"--target",
"demo:alice",
"--requested-by",
"operator-a",
],
vec!["buzz-admin", "user-deletions", "list"],
vec![
"buzz-admin",
"user-deletions",
"inspect",
"00000000-0000-0000-0000-000000000001",
],
] {
assert!(crate::Cli::try_parse_from(args).is_ok());
}
for command in ["inventory", "approve", "run", "drain", "worker"] {
assert!(crate::Cli::try_parse_from(["buzz-admin", "user-deletions", command]).is_err());
}
}
}

View File

@@ -66,10 +66,10 @@ pub(crate) use runtime::{
};
pub use store::{
admin_moderation, allowlist, api_token, archived_identities, artifact, channel,
channel_members, community, deletion, dm, event, feed, git_repo, moderation, nip_fi_binding,
operator_listener, partition, personal_read, product_feedback, push, reaction, read_state,
relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable,
storage_accounting, thread, thread_window, usage, user, workflow,
channel_members, community, deletion, dm, entity_deletion, event, feed, git_repo, moderation,
nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction,
read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder,
replaceable, storage_accounting, thread, thread_window, usage, user, workflow,
};
pub use allowlist::AllowlistEntry;

View File

@@ -543,6 +543,7 @@ mod postgres_tests {
"product_feedback",
"replica_heartbeat",
"community_deletion_requests",
"entity_deletion_requests",
"community_deletion_approvals",
"community_deletion_checkpoints",
"community_deletion_manifest_keys",
@@ -769,7 +770,12 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 67);
assert_eq!(migrations.len(), 68);
assert_eq!(migrations[67].version, 68);
assert!(migrations[67]
.sql
.as_str()
.contains("CREATE TABLE entity_deletion_requests"));
assert_eq!(migrations[64].version, 65);
let retire_ttl = strip_sql_comments(migrations[64].sql.as_str());
for statement in [
@@ -2704,13 +2710,13 @@ mod postgres_tests {
///
/// This bootstraps one probe database from `schema.sql` **through the real
/// `bin/pgschema apply` binary** — the exact path CI (`ci.yml`) and both
/// test-relay launchers take — and migrates another through 1–38, then
/// asserts the three admin tables have identical column definitions (name,
/// type, nullability, default) and identical index shapes, including each
/// test-relay launchers take — and migrates another through the latest
/// migration, then asserts the admin and entity-intake tables have identical
/// column definitions (name, type, nullability, default) and index shapes, including each
/// key's catalog sort/null options (`pg_index.indoption`). Columns are keyed
/// by name, not ordinal, because migrations append via `ALTER TABLE` while
/// `schema.sql` declares them inline — positions legitimately differ, shapes
/// must not.
/// must not. Entity-intake constraints are also compared in the live catalog.
///
/// Driving the real binary is load-bearing: `pgschema` 1.7.4 discards
/// per-key `NULLS FIRST`/`NULLS LAST` when it re-emits an index, so a naive
@@ -2768,6 +2774,21 @@ mod postgres_tests {
.expect("read index shapes")
}
async fn constraints(pool: &PgPool, table: &str) -> Vec<(String, String, bool)> {
sqlx::query_as(
"SELECT con.conname, pg_get_constraintdef(con.oid), con.convalidated \
FROM pg_constraint con \
JOIN pg_class t ON t.oid = con.conrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE n.nspname = 'public' AND t.relname = $1 \
ORDER BY con.conname",
)
.bind(table)
.fetch_all(pool)
.await
.expect("read constraint definitions")
}
let base_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
@@ -2801,18 +2822,25 @@ mod postgres_tests {
let desired = PgPool::connect(&format!("{base_prefix}/{desired_db}"))
.await
.expect("connect desired-state probe database");
sqlx::raw_sql(include_str!(
"../../../../scripts/reconcile-schema-after-pgschema.sql"
))
.execute(&desired)
.await
.expect("reconcile desired-state probe database");
let migrated = PgPool::connect(&format!("{base_prefix}/{migrated_db}"))
.await
.expect("connect migrated probe database");
MIGRATOR
.run_to(55, &migrated)
.run(&migrated)
.await
.expect("apply migrations 1-55");
.expect("apply migrations for schema parity");
for table in [
"relay_admin_actions",
"relay_admin_outbox",
"relay_operator_audit",
"entity_deletion_requests",
] {
assert_eq!(
columns(&desired, table).await,
@@ -2830,6 +2858,12 @@ mod postgres_tests {
);
}
assert_eq!(
constraints(&desired, "entity_deletion_requests").await,
constraints(&migrated, "entity_deletion_requests").await,
"constraint parity mismatch for entity_deletion_requests"
);
desired.close().await;
migrated.close().await;
for probe_db in [desired_db, migrated_db] {

View File

@@ -0,0 +1,130 @@
//! Operator-global intake for synthetic user-deletion requests.
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::{Db, DbError, Result};
/// A submitted demonstration request; no cleanup is authorized by this record.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct EntityRequest {
/// Stable request identifier.
pub id: Uuid,
/// Currently always `user`.
pub entity_type: String,
/// Synthetic `demo:` label, not a resolved account identity.
pub target: String,
/// Currently always `submitted`.
pub status: String,
/// Operator identity supplied at submission.
pub requested_by: String,
/// Optional explanation supplied at submission.
pub reason: Option<String>,
/// Time of the original submission.
pub created_at: DateTime<Utc>,
/// Time of the last change; identical resubmissions do not change it.
pub updated_at: DateTime<Utc>,
}
/// Writer-database access to synthetic deletion requests.
#[derive(Clone)]
pub struct EntityDeletionStore {
pool: PgPool,
}
impl Db {
/// Use the writer pool for operator-global request intake and inspection.
pub fn entity_deletion_store(&self) -> EntityDeletionStore {
EntityDeletionStore {
pool: self.pool().clone(),
}
}
}
impl EntityDeletionStore {
/// Save a request or return an identical previous submission.
///
/// A conflicting operator or reason returns an error without changing the
/// existing request. Concurrent identical submissions converge on one row.
pub async fn submit(
&self,
target: &str,
requested_by: &str,
reason: Option<&str>,
) -> Result<EntityRequest> {
validate_submission(target, requested_by, reason)?;
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO entity_deletion_requests (target, requested_by, reason)
VALUES ($1, $2, $3) ON CONFLICT (entity_type, target) DO NOTHING",
)
.bind(target)
.bind(requested_by)
.bind(reason)
.execute(&mut *tx)
.await?;
let request: EntityRequest = sqlx::query_as(
"SELECT * FROM entity_deletion_requests WHERE entity_type = 'user' AND target = $1",
)
.bind(target)
.fetch_one(&mut *tx)
.await?;
if request.requested_by != requested_by || request.reason.as_deref() != reason {
return Err(DbError::DeletionSafety(format!(
"target already has request {}; inspect it before resubmission",
request.id
)));
}
tx.commit().await?;
Ok(request)
}
/// Load a request from the authoritative writer database.
pub async fn get(&self, id: Uuid) -> Result<EntityRequest> {
sqlx::query_as("SELECT * FROM entity_deletion_requests WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await?
.ok_or_else(|| DbError::NotFound(format!("entity deletion request {id}")))
}
/// List up to 1,000 requests, newest first.
pub async fn list(&self, limit: u16) -> Result<Vec<EntityRequest>> {
Ok(sqlx::query_as(
"SELECT * FROM entity_deletion_requests ORDER BY created_at DESC, id LIMIT $1",
)
.bind(i64::from(limit.clamp(1, 1000)))
.fetch_all(&self.pool)
.await?)
}
}
fn validate_submission(target: &str, requested_by: &str, reason: Option<&str>) -> Result<()> {
let slug = target.strip_prefix("demo:").unwrap_or_default();
if slug.is_empty()
|| slug.len() > 64
|| !slug.as_bytes()[0].is_ascii_alphanumeric()
|| !slug
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
{
return Err(DbError::DeletionSafety(
"target must be demo:<1..64 lowercase letters, digits, underscores or hyphens>".into(),
));
}
if requested_by.is_empty() || requested_by.len() > 256 || requested_by.trim() != requested_by {
return Err(DbError::DeletionSafety(
"operator identity must be 1..256 bytes without surrounding whitespace".into(),
));
}
if reason.is_some_and(|r| r.len() > 4096) {
return Err(DbError::DeletionSafety("reason exceeds 4096 bytes".into()));
}
Ok(())
}
#[cfg(test)]
#[path = "entity_deletion_postgres_tests.rs"]
mod postgres_tests;

View File

@@ -0,0 +1,185 @@
use super::*;
async fn store() -> EntityDeletionStore {
EntityDeletionStore {
pool: PgPool::connect(&crate::test_support::database_url())
.await
.expect("connect isolated database"),
}
}
async fn assert_plain_schema(pool: &PgPool) {
let fences: i64 = sqlx::query_scalar(
"SELECT count(*) FROM pg_trigger
WHERE tgrelid = 'entity_deletion_requests'::regclass",
)
.fetch_one(pool)
.await
.expect("fences");
assert_eq!(fences, 0);
let foreign_keys: i64 = sqlx::query_scalar(
"SELECT count(*) FROM pg_constraint
WHERE conrelid = 'entity_deletion_requests'::regclass AND contype = 'f'",
)
.fetch_one(pool)
.await
.expect("foreign keys");
assert_eq!(foreign_keys, 0);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn concurrent_submissions_converge_and_conflicts_preserve_request() {
let store = store().await;
let (first, second) = tokio::join!(
store.submit("demo:alice", "operator-a", Some("manual test")),
store.submit("demo:alice", "operator-a", Some("manual test")),
);
let first = first.expect("first submission");
let second = second.expect("concurrent submission");
assert_eq!(first.id, second.id);
assert_eq!(first.status, "submitted");
assert_eq!(first.entity_type, "user");
assert_eq!(first.created_at, second.created_at);
assert_eq!(first.updated_at, second.updated_at);
assert!(store
.submit("demo:alice", "operator-b", Some("manual test"))
.await
.is_err());
assert!(store
.submit("demo:alice", "operator-a", None)
.await
.is_err());
let saved = store.get(first.id).await.expect("saved request");
assert_eq!(
serde_json::to_value(saved).unwrap(),
serde_json::to_value(first).unwrap()
);
assert_eq!(store.list(100).await.expect("list").len(), 1);
store
.submit("demo:bob", "operator-a", None)
.await
.expect("second target");
let recent = store.list(1).await.expect("bounded newest first");
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].target, "demo:bob");
assert!(matches!(
store.get(Uuid::new_v4()).await,
Err(DbError::NotFound(_))
));
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn invalid_submissions_do_not_create_rows() {
let store = store().await;
for target in [
"alice",
"demo:",
"demo:Alice",
"demo:-alice",
"demo:alice@example.com",
] {
assert!(
store.submit(target, "operator-a", None).await.is_err(),
"{target}"
);
}
assert!(store
.submit(&format!("demo:{}", "a".repeat(65)), "operator-a", None)
.await
.is_err());
for actor in ["", " operator-a", "operator-a "] {
assert!(store.submit("demo:alice", actor, None).await.is_err());
}
assert!(store
.submit("demo:alice", &"a".repeat(257), None)
.await
.is_err());
assert!(store
.submit("demo:alice", "operator-a", Some(&"a".repeat(4097)))
.await
.is_err());
assert!(store.list(100).await.expect("empty list").is_empty());
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn desired_schema_has_plain_intake_without_database_guards() {
let store = store().await;
assert_plain_schema(&store.pool).await;
crate::Db::from_pool(store.pool.clone())
.validate_deletion_catalog()
.await
.expect("community catalog");
assert!(sqlx::query(
"INSERT INTO entity_deletion_requests (target, requested_by)
VALUES ('real-user', 'operator-a')",
)
.execute(&store.pool)
.await
.is_err());
assert!(sqlx::query(
"INSERT INTO entity_deletion_requests (target, requested_by, status)
VALUES ('demo:alice', 'operator-a', 'running')",
)
.execute(&store.pool)
.await
.is_err());
store
.submit("demo:alice", "operator-a", None)
.await
.expect("valid submission");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn migration_schema_entity_intake_preserves_existing_data() {
let store = store().await;
crate::migration::run_migrations_through(&store.pool, 67)
.await
.expect("previous schema");
let community = Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, 'intake-upgrade.invalid')")
.bind(community)
.execute(&store.pool)
.await
.expect("existing community");
crate::migration::run_migrations(&store.pool)
.await
.expect("upgrade");
assert_plain_schema(&store.pool).await;
let registered: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM _operator_global_tables
WHERE table_name = 'entity_deletion_requests')",
)
.fetch_one(&store.pool)
.await
.expect("registry");
assert!(registered);
let request = store
.submit("demo:alice", "operator-a", None)
.await
.expect("submit");
crate::migration::run_migrations(&store.pool)
.await
.expect("rerun");
assert_eq!(
store
.get(request.id)
.await
.expect("preserved request")
.target,
"demo:alice"
);
let host: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1")
.bind(community)
.fetch_one(&store.pool)
.await
.expect("preserved community");
assert_eq!(host, "intake-upgrade.invalid");
crate::Db::from_pool(store.pool.clone())
.validate_deletion_catalog()
.await
.expect("community catalog");
}

View File

@@ -18,6 +18,8 @@ pub mod community;
pub mod deletion;
/// Direct message channel persistence.
pub mod dm;
/// Operator-global synthetic user-deletion request intake.
pub mod entity_deletion;
/// Event storage and retrieval.
pub mod event;
/// App-owned push enqueue and channel TTL refresh for inserted events.

View File

@@ -2,6 +2,9 @@
#![warn(missing_docs)]
//! Shared durable whole-community deletion engine and store adapters.
/// Synthetic user-deletion request intake.
pub mod user;
use std::future::Future;
#[cfg(test)]
use std::sync::atomic::{AtomicBool, Ordering};
@@ -546,6 +549,10 @@ fn resolve_submit_host(host: Option<&str>, relay_url: Option<&str>) -> Result<St
}
async fn connect_store() -> Result<DeletionStore> {
Ok(store(&connect_database().await?))
}
async fn connect_database() -> Result<Db> {
let database_url = required_env("DATABASE_URL")?;
// The scheduled drain starts in a fresh pod whose first connection can
// outlast the relay's three-second budget.
@@ -559,7 +566,7 @@ async fn connect_store() -> Result<DeletionStore> {
"deletion",
)
.await?;
Ok(store(&db))
Ok(db)
}
fn resolve_s3_region(buzz_region: Option<String>, aws_region: Option<String>) -> String {

View File

@@ -0,0 +1,53 @@
//! PostgreSQL-only intake commands for synthetic user-deletion requests.
use anyhow::Result;
use clap::Subcommand;
use uuid::Uuid;
/// Submit and inspect synthetic requests; these commands never execute deletion.
#[derive(Debug, Subcommand)]
pub enum Command {
/// Save a request. An identical resubmission returns the existing row.
Submit {
/// Synthetic user label, for example demo:alice.
#[arg(long)]
target: String,
/// Submitting operator identity.
#[arg(long)]
requested_by: String,
/// Optional explanation.
#[arg(long)]
reason: Option<String>,
},
/// List synthetic requests, newest first.
List {
/// Maximum number of rows.
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
limit: u16,
},
/// Show a saved request by UUID.
Inspect {
/// Request UUID returned by submit.
id: Uuid,
},
}
/// Run intake commands using the existing deletion database connection.
pub async fn run(command: Command) -> Result<i32> {
let db = crate::connect_database().await?;
let store = db.entity_deletion_store();
match command {
Command::Submit {
target,
requested_by,
reason,
} => crate::print_json(
&store
.submit(&target, &requested_by, reason.as_deref())
.await?,
)?,
Command::List { limit } => crate::print_json(&store.list(limit).await?)?,
Command::Inspect { id } => crate::print_json(&store.get(id).await?)?,
}
Ok(0)
}

View File

@@ -0,0 +1,72 @@
# Submit a synthetic user-deletion request
This first slice saves and reads requests. It does not discover users, build
inventory, approve requests, start workers, or delete data. Only synthetic
`demo:` labels are accepted.
## Prepare a local database
From the repository root, activate Hermit and start a separate PostgreSQL
container. These are disposable local credentials.
```bash
. ./bin/activate-hermit
docker run --detach --name buzz-user-deletion-intake \
--publish 127.0.0.1:55433:5432 \
--env POSTGRES_USER=buzz \
--env POSTGRES_PASSWORD=buzz-local-demo \
--env POSTGRES_DB=buzz postgres:17-alpine
export DATABASE_URL=postgres://buzz:buzz-local-demo@127.0.0.1:55433/buzz
cargo build -p buzz-admin
# Wait for "accepting connections" before migrating.
docker exec buzz-user-deletion-intake pg_isready -U buzz -d buzz
./target/debug/buzz-admin migrate
```
Use an unused port and container name if these are occupied. The commands below
need only PostgreSQL; a relay, Redis, and object storage are unnecessary.
## Submit and see the row
```bash
./target/debug/buzz-admin user-deletions submit \
--target demo:alice --requested-by operator-a
```
The command prints the saved row as JSON. It contains:
| Field | Value |
| --- | --- |
| `id` | New UUID; copy this for inspection |
| `entity_type` | `user` |
| `target` | `demo:alice` |
| `status` | `submitted` |
| `requested_by` | `operator-a` |
| `reason` | `null`, unless supplied with `--reason` |
| `created_at` | Submission time |
| `updated_at` | Same time; no execution has started |
List recent requests, then inspect the UUID returned above:
```bash
./target/debug/buzz-admin user-deletions list --limit 10
REQUEST_ID=<returned-uuid>
./target/debug/buzz-admin user-deletions inspect "$REQUEST_ID"
```
To see the PostgreSQL row directly:
```bash
docker exec buzz-user-deletion-intake psql -X -U buzz -d buzz -c \
"SELECT id, entity_type, target, status, requested_by, created_at
FROM entity_deletion_requests ORDER BY created_at DESC;"
```
Exactly one row is saved in `entity_deletion_requests`. Repeating the same
target, operator, and reason returns that row without changing its timestamps.
Reusing the target with a different operator or reason fails and preserves the
original row. Use a new `demo:` label for a new request.
This slice stops at `submitted`. Inventory, approval, stage history, worker
ownership, and execution will be added in later changes. Their fields and tables
can be added through normal additive migrations.

View File

@@ -0,0 +1,20 @@
SET LOCAL lock_timeout = '5s';
-- Synthetic request intake only; no deletion worker or user cleanup.
-- Operator-global intake for synthetic user-deletion requests.
CREATE TABLE entity_deletion_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL DEFAULT 'user' CHECK (entity_type = 'user'),
target TEXT NOT NULL CHECK (target ~ '^demo:[a-z0-9][a-z0-9_-]{0,63}$'),
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status = 'submitted'),
requested_by TEXT NOT NULL CHECK (length(trim(requested_by)) BETWEEN 1 AND 256),
reason TEXT CHECK (octet_length(reason) <= 4096),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (entity_type, target)
);
INSERT INTO _operator_global_tables (table_name, reason)
VALUES ('entity_deletion_requests', 'Operator-global synthetic user-deletion intake')
ON CONFLICT (table_name) DO NOTHING;

View File

@@ -122,6 +122,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
\i tables/public/push_gateway_delivery_request_replays.sql
\i tables/public/replica_heartbeat.sql
\i tables/public/community_deletion_requests.sql
\i tables/public/entity_deletion_requests.sql
\i tables/public/community_deletion_approvals.sql
\i tables/public/community_deletion_checkpoints.sql
\i tables/public/community_deletion_manifest_keys.sql
@@ -194,6 +195,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
\i seeds/push_gateway_delivery_request_replays.sql
\i seeds/replica_heartbeat.sql
\i seeds/community_deletion_requests.sql
\i seeds/entity_deletion_requests.sql
\i seeds/community_deletion_approvals.sql
\i seeds/community_deletion_checkpoints.sql
\i seeds/community_deletion_manifest_keys.sql

View File

@@ -0,0 +1,3 @@
INSERT INTO _operator_global_tables (table_name, reason)
VALUES ('entity_deletion_requests', 'Operator-global synthetic user-deletion intake')
ON CONFLICT (table_name) DO NOTHING;

View File

@@ -0,0 +1,12 @@
-- Operator-global intake for synthetic user-deletion requests.
CREATE TABLE entity_deletion_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL DEFAULT 'user' CHECK (entity_type = 'user'),
target TEXT NOT NULL CHECK (target ~ '^demo:[a-z0-9][a-z0-9_-]{0,63}$'),
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status = 'submitted'),
requested_by TEXT NOT NULL CHECK (length(trim(requested_by)) BETWEEN 1 AND 256),
reason TEXT CHECK (octet_length(reason) <= 4096),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (entity_type, target)
);

View File

@@ -147,6 +147,9 @@ run_unit_tests() {
run_test_step "buzz-admin NIP-FI binding command tests" \
cargo test -p buzz-admin nip_fi::tests -- --nocapture
run_test_step "buzz-admin user-deletion intake command tests" \
cargo test -p buzz-admin user_deletions::tests -- --nocapture
# Multi-tenant conformance gate: independent replay checker + golden
# fixtures (buzz-conformance). Pure in-process trace replay, no infra.
run_test_step "buzz-conformance tests" \