Automate owner deletion preparation (#7830)

## Why

Owner deletion admission stops at `submitted`. Manual inventory and
approval still stand between owner intent and the existing deletion
executor, preventing self-serve deletion.

## What

The existing `buzz-admin deletions drain` process prepares and
automatically approves operator-attested owner requests. Operator-origin
requests retain manual approval.

## How

The drain prioritizes approved work, then claims one owner submission
with the existing generation lease. It freezes inventory and records
digest-bound `owner_automatic` approval in one transaction before
entering the unchanged executor.

Before approval, the transaction rechecks archived state and current
ownership. It locks the deletion advisory fence, community, owner
memberships, and request in that order. A regression test pins
privileged abort during a live preparation lease.

What got simpler: one drain still owns preparation and execution. There
is no second worker, queue, lifecycle, or retry authority. The rebased
branch contains only this feature and its review fixes.

## Risk

Automatic approval permits irreversible deletion without a human grace
period. Operator attestation is not a cryptographic owner signature.
Provenance, locked authority checks, generation fences, and digest-bound
approval constrain the operation.

This remains work in progress. Broad test failures and incomplete mobile
checks are disclosed below; publication is not a merge-ready verdict.

## Testing

Published head: `e059ae9056b447ca3b5d0c2893da86550ef16f5c`. All seven
commit trees match tested source
`5605f27eaece1e809f746cc1839b55c7ceef8d80`; only attribution metadata
changed. Source-SHA results below are not new-head execution receipts.

At source commit `5605f27eaece1e809f746cc1839b55c7ceef8d80`, isolated
PostgreSQL tests showed stale archive and ownership fixtures fail closed
after the fix. Before the fix both reached automatic approval. Removing
abort's generation increment or stage transition made the new live-lease
regression fail; restored production passed.

`just test` failed in two ACP native-git fixtures and one agent
body-timeout test. The failing source files match the pinned base, but
there is no exact-base execution comparison proving those failures
unrelated. `just test-unit` also failed in the ACP fixtures, leaving 18
tests unrun. `just ci` stopped in Flutter tooling package resolution;
separately run non-mobile lanes do not constitute a full `just ci` pass.

No live tenant deletion or human testing was performed. A dedicated
populated `0052` → `0053` approval-backfill fixture remains a coverage
gap. Final-head hosted CI and security review are required.

## Bigger picture

Rebased directly onto main `12670bd0f037c66a682272bb81c46c3f254fad74`;
prerequisite #7818/#7827 are already merged. Main owns
`0052_channel_artifacts`, so this feature's automatic-approval migration
is now `0053`. Client/KGoose and quota work are separate PRs and are not
part of this diff.

Generated with Codex

---------

Signed-off-by: Codex <noreply@openai.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
Luke Tornquist
2026-09-29 11:39:33 -04:00
committed by GitHub
parent 12670bd0f0
commit bba75a71eb
8 changed files with 1654 additions and 44 deletions

View File

@@ -705,9 +705,12 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 52);
assert_eq!(migrations.len(), 53);
assert_eq!(migrations[48].version, 49);
assert_eq!(migrations[49].version, 50);
assert_eq!(migrations[50].version, 51);
assert_eq!(migrations[51].version, 52);
assert_eq!(migrations[52].version, 53);
assert!(migrations[48]
.sql
.as_str()
@@ -716,6 +719,11 @@ mod postgres_tests {
.sql
.as_str()
.contains("community_deletion_owner_provenance"));
assert!(migrations[52].sql.as_str().contains("approval_origin"));
assert!(migrations[52]
.sql
.as_str()
.contains("community_deletion_requests_owner_preparable"));
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -1722,6 +1730,34 @@ mod postgres_tests {
);
}
#[test]
fn owner_deletion_auto_approval_migration_matches_desired_schema() {
let migration = MIGRATOR
.iter()
.find(|migration| migration.version == 53)
.expect("embedded migration 0053")
.sql
.as_ref()
.to_ascii_lowercase();
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace root");
let schema = std::fs::read_to_string(workspace_root.join("schema/schema.sql"))
.expect("read schema/schema.sql")
.to_ascii_lowercase();
for sql in [&migration, &schema] {
assert!(sql.contains("approval_origin text not null default 'operator'"));
assert!(sql.contains("approval_origin in ('operator', 'owner_automatic')"));
assert!(sql.contains("'submitted', 'approved', 'fenced'"));
assert!(sql.contains("community_deletion_requests_owner_preparable"));
assert!(sql.contains("request_origin = 'owner'"));
assert!(sql.contains("stage = 'submitted'"));
}
assert!(migration.contains("set local lock_timeout = '5s'"));
}
/// Structural parity between migration 0029's deletion surface and the
/// desired-state bootstrap schema (`schema/schema.sql`).
///
@@ -1866,13 +1902,50 @@ mod postgres_tests {
.tables
.get(table)
.unwrap_or_else(|| panic!("schema.sql is missing deletion table {table}"));
if table != "community_deletion_requests" {
if table != "community_deletion_requests" && table != "community_deletion_approvals" {
assert_eq!(
in_schema, definition,
"schema.sql definition of {table} drifted from migration 0029"
);
}
}
let migration_approval_table = migration
.tables
.get("community_deletion_approvals")
.expect("0029 approval table");
let schema_approval_table = schema
.tables
.get("community_deletion_approvals")
.expect("schema.sql approval table");
for invariant in [
"inventory_digest bytea not null check (length(inventory_digest) = 32)",
"foreign key (request_id, community_id, inventory_digest) references community_deletion_requests(id, community_id, inventory_digest) on delete restrict",
] {
assert!(
migration_approval_table.contains(invariant),
"0029 deletion approvals are missing {invariant}"
);
assert!(
schema_approval_table.contains(invariant),
"schema.sql deletion approvals are missing {invariant}"
);
}
let migration_request_table = migration
.tables
.get("community_deletion_requests")
.expect("0029 deletion request table");
for request_table in [
migration_request_table,
schema
.tables
.get("community_deletion_requests")
.expect("schema.sql deletion request table"),
] {
assert!(
request_table.contains("unique (id, community_id, inventory_digest)"),
"deletion requests must expose the exact composite approval target"
);
}
for (function, definition) in &migration.functions {
let in_schema = schema
.functions