Commit Graph

3 Commits

Author SHA1 Message Date
ravarora2
834dca26bf Generate user community inventory for the dummy deletion workflow (#8605)
## Summary

User deletion can now discover current communities from a hex public key
or npub, save that inventory, and run it through the existing approval
and dummy workflow. `--pubkey demo` remains the default. Execution only
prints messages; it does not fence users or delete data.

Discovery combines explicit relay memberships with active channel
memberships, including users who have no relay membership row. It
deduplicates communities and preserves owner relationships. The saved
manifest contains only community IDs, labels and relationships.
Historical discovery and owned-community execution remain later work.
Submission rejects owner inventories atomically with a specific
unsupported-execution error; preview still includes owners.

This is an expand-schema change after #8520. Migrations 0072–0073
replace the demo-only target check with a bounded identifier check, with
5-second lock and 30-second statement timeouts. The database keeps the
user-only entity-type check while allowing different identity formats in
the target field. Workflow-specific commands reject entity types they
cannot execute. The application currently resolves demo and hex/npub
inputs; account-ID lookup is future work. The desired-state constraint
matches. No backfill, trigger, function, or foreign key is added.
Existing demo rows remain valid. Old binaries fail closed on new
manifests; use the new CLI for their approval and execution. Older
auto-migrating binaries need the documented rollback procedure.

Identity parsing and demo fixture selection stay in intake. Approval and
resume use the saved target without reparsing its format. The user
workflow defines its stage names and handlers; CLI stage validation
reads those rules instead of a separate three-name list. Shared
persistence accepts caller-defined stages. Permanent target uniqueness
is unchanged. Dummy submissions and execution belong in isolated,
disposable development databases; production preview uses read-only
access. CLI operator names remain self-asserted.

### Related issue

Follows #8520. Searched open PRs; no duplicate inventory change found.
#8489 is earlier workflow scaffolding.

### Testing

Built `buzz-admin` locally and applied all migrations to a fresh
disposable PostgreSQL database. Ran the actual CLI through preview →
submit → inspect → approve → `run --next-step` → resume → completed for
both demo and a public key with a channel-only membership fixture. Demo
completed five stages; the public-key run completed three. Injected
`--fail-stage purge_db`, waited for the retry deadline, and resumed the
same failed stage successfully. Membership data remained intact.

Repeated the CLI workflow on October 26 for demo and an npub backed by a
fresh production inventory. Production access used read-only credentials
and the real `inventory --pubkey "$SUBJECT"` handler. Copied only the
relevant discovery fields into an isolated local PostgreSQL database;
local channel parent rows supplied placeholder fields for foreign keys.
The inventory saved by local submission exactly matched the production
result. All workflow writes stayed local.

For each input, ran `submit --pubkey "$SUBJECT" --requested-by
local-e2e-submitter`, inspected the saved inventory, and confirmed
execution before approval was rejected. Approved its digest, then ran
each handler with `run "$REQUEST_ID" --next-step --stage-delay-ms 2500`
in a new process. Captured all three workflow tables during and after
every stage. Demo completed five stages across two communities; the npub
completed three stages for its discovered community. Each handler saved
`dummy: true`. Both requests finished with no current stage, no worker
lease, and no retries. Copied memberships remained intact. This
additional run exercised orderly resume; it did not inject a failure.

The reproducible command sequence and fixture setup are in [the
inventory walkthrough](docs/operator-user-deletion-inventory.md).
Production was not changed. The earlier full-workflow runs used
synthetic local fixtures; the additional npub run used captured
production discovery fields. Repository-wide `just ci` was not run;
validation focused on the affected packages and PostgreSQL workflow. The
pre-push desktop build failed with “Too many open files in system.”
Stopped the broader hook run and bypassed it to publish the earlier
commits; the affected-package Clippy and formatting checks had passed.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-10-26 15:44:12 -04:00
ravarora2
4250014bae Run user deletion through saved stage transitions (#8520)
## Summary

An operator can submit, approve, and run a synthetic user-deletion
request through a durable workflow. The actions print messages; they do
not gate real users or delete data. The inventory contains artificial
participant and former-participant communities.

The worker calls one transition function, `get_next_stage(lease,
previous_stage_id)`. It receives a full stage row, passes it to the
handler, then asks again. All branching, community tracking,
initialization, and recovery live behind that function. Existing
community-deletion execution is unchanged.

### Related issue

Builds on merged #8496 and targets `main`. Inventory, separate approval
records, and the dummy executor remain together in this PR.

### Execution design

```text
approve → save approval; create no stages
run → claim request
    get_next_stage(lease, None) → full stage row
    handler(lease, row) → persist status and result
    get_next_stage(lease, Some(row.id)) → next row / Finished / WaitUntil
    repeat, then release ownership
```

- **Approval:** bind an operator's decision to the saved inventory
digest. Approval creates no execution rows. Operator names remain
self-asserted demo input.
- **Initialization:** claiming preserves the parent lifecycle. The first
transition creates `gate_user` and changes `approved` to `running`
atomically. A crash after claim but before initialization can safely
restart.
- **Handler:** read `metadata_json.inputs`, mark the row running, and
save its result or failure. A successful action leaves its row
`completed` and `is_current = true`. This is a durable transition
waiting to be processed.
- **Transition:** lock the parent, validate ownership, reload history
and the handler's saved result, and choose an allowed destination. Clear
the old current flag, save the decision, and insert the successor with
its inputs in one transaction. Terminal transitions complete the parent
instead. An insertion failure leaves the completed old row current.
- **Recovery:** `previous_stage_id` identifies the handler that just
returned; it must belong to the request. A restarted worker passes
`None` and finds the saved current row. If that row is completed,
transition it without repeating its action. If the switch already
committed, return the saved successor. Repeated calls never apply the
old transition twice.
- **Progress:** reconstruct covered communities from saved decisions and
the approved inventory. Each stage records its predecessor ID, so
reconstruction does not depend on timestamp ordering. Missing current
state in a started request, disconnected history, unknown stages, and
malformed results fail without inventing progress.
- **Ownership:** parent leases last 60 seconds and handlers renew them
every ten seconds. Generation checks reject stale writes. Handler and
renewal futures remain independently polled; renewal cannot prevent a
handler holding the parent lock from committing.
- **Step-by-step testing:** `run --next-step` executes one handler,
commits its transition, and exits with the successor pending. Repeat the
same command to advance again; the final step completes the request. The
former `--max-stages` and `--pause-after-handler` flags are removed.
- **Failure:** the handler saves the current row's error and a 30-second
retry deadline. The transition returns `WaitUntil`; the worker exits and
releases its lease. A later invocation retries the same row. No
background scheduler or automatic retry daemon is added.

The user workflow definition in `buzz-deletion/src/user/workflow.rs`
declares allowed destinations and a decision function per stage.
`gate_user` enters the first uncovered community. `purge_db` reads saved
`has_agents`: true selects `purge_agents`; false completes the community
immediately. Community completion selects the next uncovered community,
or finishes the request when all are covered. No full stage plan is
created upfront. The transition transaction contains no stage-name
comparisons or fixed community count.

Stage metadata stores `inputs`, `result`, `transition`, and
`previous_stage_id`. The parent retains inventory, ownership, retry, and
overall status. Approval evidence stays in `entity_deletion_approvals`.
A completed-current row bridges the two transactions; real handlers will
still need idempotent or reconciled actions if a process dies before
saving its result.

### Code organization

`buzz-deletion/src/user/worker.rs` runs the loop and heartbeat. Sibling
`handlers.rs` executes dummy actions. `transition.rs` defines
`UserDeletionTransition.get_next_stage()`, and `workflow.rs` owns the
user stage rules and community-progress reconstruction. The CLI remains
in `user.rs`.

`buzz-db/src/store/entity_deletion/` contains shared workflow
persistence: a small `mod.rs`, request reads/inspection in
`requests.rs`, approval evidence in `approvals.rs`, ownership in
`leases.rs`, and stage outcomes/atomic transitions in `stages.rs`.
User-specific submission, demo inventory, and inventory validation live
separately in `buzz-db/src/store/user_deletion/{requests,inventory}.rs`;
its `mod.rs` only defines and exports the store.

The worker calls the user transition object, which supplies a
synchronous decision function to `EntityDeletionStore.transition_stage`.
The store locks the parent, reads saved state, invokes that function,
checks the returned operation, and commits its writes. The decision
function performs no SQL or external calls. Reading, deciding, and
writing remain in one transaction; there is no stale-state gap. Shared
storage contains no user stage names or community-routing rules.
User-specific approval validation also runs inside the shared approval
transaction.

This reorganization changes no tables, migrations, stage metadata, or
CLI behavior. Requests using the existing saved-result format continue
to work. `--next-step` still runs one handler and commits its transition
before returning.

### Testing

The walkthrough in
[docs/operator-user-deletion-executor.md](https://github.com/block/buzz/blob/rarora/user-deletion-approval/docs/operator-user-deletion-executor.md)
includes commands, SQL inspection, expected rows, and recovery
boundaries.

Submitted and approved a fresh local request and verified zero stage
rows. Ran `run --next-step` five times. Each call completed exactly one
handler, released ownership, and left its successor pending: B DB, B
agents, C DB, C agents, then request completed. A sixth call left saved
state unchanged. Verified both removed flags are rejected.

Ran `--demo-no-agents-in 00000000-0000-0000-0000-00000000000c` and
verified four completed rows, with no C agents stage.

Injected a DB-stage failure, verified immediate retry refusal, waited
for the real deadline, and resumed the same stage row. Observed
heartbeat renewal and rejection of a competing worker, killed the owned
worker process, waited for its actual lease expiry, and resumed the same
unfinished stage with a new generation. Re-running a completed request
left its saved state unchanged. Repeated the CLI walkthrough after the
file reorganization, including runtime branching and a retry after the
real deadline; a completed request created before the reorganization
remained unchanged.

Mutation-tested the ownership and recovery guards against local
PostgreSQL 17. Reclaiming with the same executor ID rejects
old-generation operations. Independently disabling the ownership
generation check, release generation check, saved-transition/result
comparison, or heartbeat-failure handling made the corresponding test
fail. Restored all four protections afterward. The heartbeat test takes
over the request while its handler is already running.

### Schema and rollout

Unmerged migrations 0069–0070 add inventory and execution fields, widen
request lifecycle states, and create operator-global approval and
stage-history tables. They set five-second lock and thirty-second
statement timeouts; constraint validation has its own transaction. No
triggers, functions, foreign keys, or data backfills are added.
Desired-state and migration catalog parity cover all three tables. The
transition redesign adds no further schema changes.

The serving relay does not schedule these requests. Only the explicit
operator CLI runs them, using PostgreSQL. Only `demo:` targets are
accepted. Real account discovery, user-write fencing, owned-community
handling, and actual deletion remain future work. Verify staging with a
new demo label and the documented commands.

Older draft requests do not have the new stage metadata contract.
Preserve them and create a new demo request for this version; they are
not a supported resume format. Use a fresh database if an earlier draft
applied a different migration checksum. Never alter sqlx migration
checksums. With Helm `migrate.autoMigrate=true` (the chart default), a
relay binary that lacks 0069/0070 fails at boot with sqlx
`VersionMissing` after those migrations are applied. Recovery for this
rollout is a roll-forward: deploy a corrected binary that retains the
migrations. Do not delete migration records or change checksums to force
an older binary to boot. Rolling back with auto-migration disabled
requires a separately verified schema-compatibility plan. Demo requests,
stage history, and approvals are intentionally retained; this scaffold
has no cleanup command or retention job. Recheck migration numbering
before merge.

Generated with Codex

---------

Signed-off-by: Ravneet Arora <rarora@squareup.com>
2026-10-26 10:44:32 -04:00
ravarora2
2d289f720d 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>
2026-10-22 10:49:27 -04:00