Skip to content

feat(relay): record where media is shared and who uploaded it - #8451

Open
ngoodger-block wants to merge 19 commits into
block:mainfrom
ngoodger-block:feat/media-shares
Open

ngoodger-block wants to merge 19 commits into
block:mainfrom
ngoodger-block:feat/media-shares

Conversation

@ngoodger-block

@ngoodger-block ngoodger-block commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #8535 (explicit schema fence bootstrap). This prerequisite must land before the media tables.

Why?

Media is readable by anyone in a community who has its URL, even when it was posted in a private channel or DM. Restricting reads to the channels a file is shared in needs a record of where each file is shared — including everything posted before — so enforcement can land without hiding existing media.

What?

Migration 0068 starts with a five-second local lock timeout, so blocked DDL fails for retry. It adds uploader proof and a separate file grant for each source event. Each grant records the source message’s event_id and event_created_at, plus its channel or the whole community for a channel-less reference. A leaked URL creates no grant: the author must have uploaded the exact bytes or already have a readable share. Author-only and recipient-gated events share nothing. An agent’s kind:0 picture may use its registered owner’s upload proof; other fields cannot.

Deleted or purged source events cannot authorize shares. Edit grants record the original message ID and timestamp; both the edit and its target must remain live. Profile and emoji replacements recover eligible pre-cutoff references before retiring the old head, in the same transaction. The backfill applies the same lifetime rules. Both schema paths use application admission and explicit deletion coverage, without new foreign keys or fence triggers. Download behavior stays unchanged here; #8444 uses these records to enforce reads.

Before recording shares, backfill restores uploader proof from retained server-authored media_uploaded audit records in bounded, tenant-admitted batches. Only attributed 32-byte uploader keys and valid SHA-256 object IDs establish ownership. Missing or malformed evidence requires uploading the exact bytes again; it creates no ownership. The CLI rejects non-positive batch sizes before connecting. Agent engrams (kind 30174) are excluded from grants, alongside other private event kinds.

The elected usage-poller leader reclaims inactive grants in bounded pages. It removes obsolete public profile/emoji grants and compacts inactive channel history to at most one row per community and file. That last row preserves the uploader-preview restriction after message deletion; it grants no read access. Cleanup uses community admission and skips quiescing/deleting communities.

Run buzz-admin backfill-media-shares after every relay pod records shares. It trusts events received before the cutoff stored on each community and checks later references like live writes. The cutoff exists in both desired-state and SQLx-managed databases. Every run, including the first, rechecks later references using current permissions, so an earlier refusal can become a lasting grant. Before the first backfill, explicitly accept the resulting grants or defer backfill until refusal-preserving behavior exists. Changing read modes does not revoke existing grants. Every invocation restarts the scan and deduplicates rows; it does not preserve earlier authorization decisions.

Related issue

Recording half of the file-access stack; #8444 adds read enforcement.

Testing

Bootstrapped isolated PostgreSQL 17 with pgschema apply and the reconciliation script, then exercised backfill against that database without a SQLx ledger. On the stacked local relay, backed by PostgreSQL, Redis, and an S3 HTTP fixture, all 29 media end-to-end tests passed. They cover private reads, owner-uploaded agent avatars, and author/moderator deletions that revoke retained and edit-added attachments while independent shares survive. The profile journey also retains a copied avatar across a name update, then revokes access when the picture is removed. The actual Desktop edit/upload/profile workflow smoke test remains pending: an isolated native demo build started, but UI automation returned cgWindowNotFound before the edit flow could be exercised.

Held a conflicting communities lock in isolated PostgreSQL and ran the exact migration SQL with session timeouts disabled: it failed with SQLSTATE 55P03 after 5.04 seconds, left no partial schema, then committed on retry after the lock was released. Removing the migration's timeout made the embedded-migration assertion fail.

The real PNG upload journey passed against the recording-only relay and checked the tenant-scoped authenticated uploader row; removing the handler’s uploader write made it fail. A real upload database failure returned 503 and logged the database error without a false lease-loss warning. After removing an uploader row from a real server upload, the actual backfill CLI restored it from its retained audit record. PostgreSQL regressions passed on both desired-state and migrated schemas; persisted private-kind references created no grants, and malformed/unattributed audit evidence conferred no ownership. Mutations that admitted an engram or treated a non-upload audit as ownership both failed.

All 29 live media journeys passed again with a five-second usage-poller interval. Deletion and profile journeys waited for periodic cleanup: old public grants disappeared, and deleted channel attachments remained 404 after compaction. The private-channel journey added then removed a member using real signed membership events, verifying GET and HEAD changed from 200 to 404 while the owner retained access. Removing the membership tombstone check made that regression fail. Removing the live-source check or deleting the final channel boundary also failed authorization regressions.

The fresh security workflow failed before analysis because checkout refused fork code in a privileged pull_request_target job; the protection remains enabled, and this run provides no security review result.

Generated with Codex

Linear: BUZZ-358

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is dc25198d530acc6237a0320b697365f29daf0ea6...bb447892964c4b208416b722700489dd55b3e6bf.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review bb447892964c4b208416b722700489dd55b3e6bf to authorize a new review.
Any previous review applies only to its recorded range.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 thanks for splitting this out from the enforcement PR. two blocking issues, both about how a share gets created, plus a schema question and some smaller items inline.

  1. a file with no channel shares yet can be made community-wide by anyone who has its hash, because the "no channel shares" arm of readable_sql! also decides who may create a share.
  2. backfill-media-shares replays every stored event without the author check, so it turns references the live path refused into community-wide shares.

we reproduced both in a throwaway Postgres using migration 0067 and the production SQL pulled out of media_reference.rs. a non-uploader's kind:1 note created a public share before the backfill and before a fresh uploader's first post. for a reference the live path had refused (0 rows written), the backfill then wrote the public share (1 row). that was a SQL-level check, not a full relay run. #8444's stricter read check still returned true for that user in all three cases, so it can't undo grants this PR already created. these need fixing here before enforcement in #8444 protects anything.

Comment thread migrations/0068_media_access.sql Resolved
Comment thread crates/buzz-db/src/store/replaceable.rs Resolved
Comment thread crates/buzz-relay/src/api/media.rs Resolved
Comment thread crates/buzz-admin/src/main.rs Resolved
Comment thread crates/buzz-core/src/media.rs Resolved
@wpfleger96

Copy link
Copy Markdown
Member

@buzz-security-review 86525c2

@ngoodger-block

Copy link
Copy Markdown
Contributor Author

🤖 Both blocking grant-creation issues are fixed in this PR with regression coverage, and source-event provenance now ensures deleted messages stop granting file access while surviving shares continue working.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 thanks for the quick turnaround. this round closes nearly everything from the first review: hash-only sharing is gone, shares now carry their source event so deletion revokes them, the savepoint ordering is fixed, and the migration-67 fixture is updated. one blocker is left, in the backfill (inline). two nonblocking notes are inline too.

the deferred follow-ups (lease-lost logging, engram 30174, excluded-kind coverage, uploader recovery from audit_log) are fine as follow-ups.

Comment thread crates/buzz-relay/src/api/media.rs Resolved
Comment thread migrations/0068_media_access.sql Resolved

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket (systems/lifecycle), Mantis (correctness/security/concurrency), and Groot (product/operations) reviews.

Two new P2 findings inline: retaining a legacy avatar during a profile update before backfill loses its grant, and deleting an edited message leaves the edit’s independent grant active. These affect the grant population created here; download enforcement remains in #8444.

The existing permission-drift finding in #8451 (comment) still applies; this review does not duplicate it. The rolling-deploy gap also remains an enforcement prerequisite: schema installation precedes the last legacy writer, so old-pod uploads/references after the cutoff may have neither proof nor shares. Preserve the accepted historical-uploader recovery follow-up and resolve that gap before relying on complete coverage. Merely moving the cutoff later can trust references the new recorder deliberately refused.

Public material: please remove the internal tracker URL from the public description’s Related issue section, replacing it with a public description of the work. This is a P3 publication cleanup, not a credential finding. No attached images were present in the description/discussion inspected; the linked issue’s contents were unavailable to this review.

Minimalism 9/10; elegance 9/10; correctness 7/10 because the two inline lifecycle cases need explicit authorization provenance and regression coverage. Existing deferred logging, kind-exclusion and cleanup suggestions remain follow-ups.

Reviewed head 7f7f21c7ea0a5c6ddd39d807dcadf13233ea6723 against base/merge-base 6e6173acd58029bac800583c4cc7482dafed693a. Source only: no PR code, tests, SQL, builds, apps, live workflows or benchmarks executed. One head-bound CI snapshot showed 55 successful and 30 skipped checks; this does not establish runtime correctness. Database races, rollout recovery, performance, and native upload/error/retry focus behavior remain unverified; the PR itself records its Desktop workflow smoke test as pending. Non-blocking COMMENT review, not approval or merge authorization.

Comment thread crates/buzz-db/src/store/replaceable.rs Resolved
@ngoodger-block

ngoodger-block commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 @wpfleger96 Please re-review the corrected backfill guarantees and the explicitly accepted limitation for recording/log-only rollout at 61ef9189a91d3fc48d4620050a877f65d2529c0f; enforcement still requires an explicit decision on replaying earlier refusals. The stack is refreshed from main, and focused media, database, membership-write, and event-insert checks passed. The fresh security workflow stopped before analysis because actions/checkout refuses fork code in a pull_request_target run; a maintainer needs to address the workflow before the security review can complete.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket (systems/lifecycle), Mantis (correctness/security/concurrency), and Groot (product/operator documentation) reviews.

One new P2 integration finding inline: the main merge brings in the database architecture contract from #8273, while this PR still introduces the fence triggers and foreign keys that contract prohibits. This is a source-demonstrated repository-contract conflict, not a newly demonstrated runtime exploit.

The six previous feature patches remain unchanged. The NIP-43 snapshot merge resolution uses the updated event hook consistently; no new runtime defect found in that integration. The backfill documentation now describes current-permission reevaluation. Optional wording: use “any backfill run” rather than “reruns” in the admin help and Db docs, since the first backfill can also reconsider a reference refused by live recording.

Prior review findings remain outstanding; this documentation/merge follow-up does not resolve the legacy-avatar or edit-lifetime issues or the public-description cleanup. They are not duplicated here. The stated acceptance of permission drift for recording/log-only rollout does not establish enforcement readiness.

Reviewed head 61ef918 against base 7e64612, focusing on changes since 7f7f21c. Source only: no tests, builds, SQL, app or CLI execution. CI snapshot: 44 successful, 29 skipped, 2 in progress, and Run Codex Security Review failed; failure cause unverified. Runtime migration parity, rollout behavior and Desktop edit/upload/profile/error-retry flows remain unverified. Public description inspected, no attached images; linked issue content not inspected. Non-blocking COMMENT review; no approval or merge authorization.

Comment thread migrations/0068_media_access.sql Resolved
ngoodger-block and others added 8 commits October 22, 2026 20:11
Add `media_shares` and `media_uploaders` (migration 0067) and start filling
them, without changing who can read media yet. Each local `/media/<sha256>`
path in a stored event shares that blob into the event's channel (or with the
whole community when channel-less), in the same transaction as the event,
when its author can read the blob or uploaded those bytes; each upload records
its uploader under the serving-write lease. `buzz-admin backfill-media-shares`
records shares for events stored before this, so channel-scoped reads can be
enforced next without hiding existing media.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Clients upload a file before attaching it, so record the uploader first and
keep the test valid once reads deny files not yet shared into any channel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Trust references only from events received before migration 0067 was
installed; later events get the live, checked recording, so a backfill run
after rollout never shares a blob that live recording refused. Insert shares
in (hash, channel) order from both the backfill and live recording so the two
cannot deadlock, and read media paths in JSON content that escapes `/`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Require upload proof or a recorded share when recording new references. Let an agent publish only its profile picture using its registered owner's upload proof. Keep the historical backfill cutoff on communities for both schema setup paths, and advance the deletion fixture through migration 67.

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
* ngoodger/explicit-schema-fences:
  fix(db): bootstrap only declared legacy fences

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
* ngoodger/explicit-schema-fences:
  test(db): audit the schema bootstrap fixture

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket (systems/lifecycle), Mantis (correctness/security/concurrency), and Groot (product/operations) reviews.

The rebase integration and migration 0067→0068 rename introduce no newly identified regression. The embedded migration assertions and fixtures follow the renumber consistently; the rebased snapshot writers retain the full-event follow-up hook inside their admitted transactions.

One additional P2 inline: bound the media migration’s lock wait. This is a newly identified material rollout risk in unchanged code, not a regression caused by the renumber: tracing the migrator shows that it explicitly disables session lock/statement timeouts, so the migration has no fallback bound when waiting for the communities table.

The legacy-reference and edit-lifetime findings and database-contract finding remain unresolved; they are not duplicated inline. Preserve the stated recording/log-only permission-reevaluation decision and resolve the previously identified rollout coverage gap before download enforcement. The prior public-description cleanup also remains: its internal tracker link is still present. No attached images appeared in the inspected description/discussion; linked issue contents were not inspected.

Minimalism 9/10; elegance 9/10; correctness 7/10 while the linked lifecycle/schema issues and migration lock bound remain unresolved.

Reviewed head 3d77f74 against base/merge-base 347ce88, focusing on changes since 61ef918 and the concrete migration risk above. Source only: no tests, SQL, builds, apps, backfill or PR code executed. One head-bound CI snapshot showed 49 successful, 29 skipped, one in-progress and one failed check; failure cause unverified. Runtime rollout, concurrency, performance and native upload/edit/profile error-retry/focus behavior remain unverified; the description still records the Desktop smoke test as pending. Non-blocking COMMENT review, not approval or merge authorization.

Comment thread migrations/0068_media_access.sql Resolved
@ngoodger-block

ngoodger-block commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 @wpfleger96 please re-review this updated stack at 11f017f: edit grants follow the original message lifetime, legacy profile/emoji replacements recover eligible references, the rollout decision precedes the first backfill, and the media migration now has a tested five-second lock budget; #8535 is the schema prerequisite, all 29 real relay media journeys passed, and the actual Desktop UI smoke test remains pending.

The security workflow has failed before analysis because checkout blocks fork PR code under pull_request_target; this is not a security pass.

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
@ngoodger-block

ngoodger-block commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 @wpfleger96 please re-review 2a67653: the deferred feedback is now implemented, including uploader-handler coverage, inactive-grant cleanup, historical uploader recovery, private-kind exclusions, accurate error logging, positive batch sizes, and removed-member access coverage; all 29 live media journeys and the targeted mutation checks passed.

The author’s accepted backfill replay limitation remains documented; its conversations were closed as an accepted limitation, not a code fix. The actual Desktop UI smoke test remains pending. The fresh security workflow failed at the fork-checkout guard before analysis; the protection remains enabled, so there is no new security review result.

The optional-fence test assumed every scoped table is fenced, so the first
tenant tables added under docs/database.md (scoped but admitted only in the
app) broke it. Assert that fences exist exactly when the fence objects do and
cover only scoped tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
ngoodger-block added a commit that referenced this pull request Oct 27, 2026
…trap (#8620)

### Why?

This is a cleanup that brings the schema bootstrap in line with how the
codebase works today, as asked for by
[`docs/database.md`](https://github.com/block/buzz/blob/dc25198d530acc6237a0320b697365f29daf0ea6/docs/database.md#L103-L124)
(#8273). Writes are now blocked during community deletion by application
admission (`begin_community_write_transaction`), and that doc's Rule 1
says new tables must not add triggers. But
`schema/community_write_fence.sql` still looped over every table with a
`community_id` column and attached the old fence trigger to it. That
meant a new table got a trigger on a fresh database and none on a
migrated one, so the two schema paths disagreed and new tenant tables
were blocked. The doc says this bootstrap change must land "before the
feature, or as the feature's first PR."

### What?

Deletes the blanket fence loop and its `\i` from `schema/schema.sql`.
Existing legacy tables keep their fences through their explicit
`schema/triggers/` files, so no current table loses its fence and
runtime behavior is unchanged. Adds a test that a new `community_id`
table stays unfenced while `events` and `users` stay fenced, and updates
`docs/database.md` and `ARCHITECTURE.md` to match. It stands on its own;
the media-sharing PRs (#8451, #8444) are the first to rely on it.

buzz-review-completed

---------

Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
wesbillman pushed a commit that referenced this pull request Nov 2, 2026
…ests (#8740)

## Summary

Two buzz-relay test fixes from the 23–30 Oct flake audit. Both are
test-only. This is one of six PRs split from the reviewed `flakey-tests`
branch (siblings below).

### `thread_window_bridge_restarts_both_aux_hops_after_replica_failure`
(3 runs)

- **Runs:** 40799617319, 40924179886 and 40930100994 (#8697, docs only),
all on 10-29 in PostgreSQL Tests, attempt 1 (jobs 123208043033,
123632125866, 123651641392). None of the branches touches thread_window,
and each run passed on a re-run. Signature:
`failure_postgres_tests.rs:46`, "barrier must precede closure
completion", left: 2, right: 1.
- **Cause:** `pause_after_aux_page` polls the request until a
`thread_window_aux` span closes, then expects exactly one. The fixture's
1,001 reactions make two replica pages at `AUX_LIMIT` 1000, and each
page is one round trip on the transaction already held. A loaded runner
can deschedule the test thread after page 2's query is written. Postgres
then answers first, and the same poll reads page 2 too.
- **Change:** `test_support` gains a `PausingProxy`, a trimmed copy of
buzz-db's private one with only the hold-everything switch. The replica
reader pool connects through it. `AuxPages` pauses it when the first aux
span closes. That runs inside the request's poll, after page 1's
response is consumed and before page 2's query is written, so page 2
stays pending however fast Postgres answers. The test resumes the proxy
after it terminates the reader's backend. The held page then sees the
termination, and the closure restarts on the writer, as before. The
other three `pause_after_aux_page` callers read from the writer and pass
`None`.

### `api::media` tests that need Postgres (fail every time Flake Check
selects them)

- **Run:** 40954304982 (#8658), 10-29, Flake check (Rust), job
123730434146. All six `test_state()` tests panic at the community-seed
`expect` after the 30 s sqlx acquire timeout.
- **Cause:** when a hunk lands outside a test body, Flake Check selects
every test in the file. It skips only `postgres_tests::`, `postgres_*`
binaries and `external_infra`, and its job has no database. These six
tests seed `relay.example` through Postgres but sat in plain `mod tests`
without `#[ignore]`. So any PR that changes non-test code in `media.rs`
fails that lane, and at least 8 open PRs touch the file. No other lane
ran these tests either.
- **Change:** move `test_state`, the `media_get_*` helpers,
`media_request` and the six tests into `tests::postgres_tests`. Mark
each test `#[ignore = "requires Postgres"]`, and point `test_state` at
`test_support::database_url()`, as the module's other states do. The
PostgreSQL lane now runs them, and Flake Check skips them. The change
also drops imports `mod tests` no longer uses, and updates the Justfile
comment that gave `api::media` as the reason the infra-free lane is
scoped to `api::admin`.
- When `media.rs` is staged, the pre-commit secret scan reads the whole
file and flags the module's existing public ES256 test key
(`TEST_EC_PKCS8_PEM`). The key is now written on one line with a sadscan
marker, as `invites.rs` already does. Its bytes are unchanged.

### Related issue

None found. **Conflicts with #8451 and #8444**, which both already
conflict with `main`. The one-line `TEST_EC_PKCS8_PEM` rewrite is the
only new conflict hunk with each. The test move merges cleanly with
both.

### Testing

Verified locally:
- Each of the four `pause_after_aux_page` tests passes when run alone
against local Postgres through `scripts/postgres-test-run.sh`.
- A forced check, not committed: after the barrier, the test gave page 2
another 500 ms over ten re-polls and asserted the page count stayed at
1. It passed with the pause and failed with `None`, with the CI
signature (left: 2, right: 1). A true same-poll burst needs the OS to
preempt between sqlx's write and read, so it was not forced directly.
- `check-postgres-test-discovery.py` accepts the touched files and all
621 files under `crates/`, and `scripts/test-postgres-test-discovery.sh`
passes. Each of the six media tests passes when run alone against local
Postgres.
- `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets --
-D warnings` are clean under 1.98.1.

Only hosted CI can show the full lanes, which were not run locally. The
six media tests have never run in CI, so this PR's PostgreSQL lane is
their first hosted run.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
bradseiler added a commit that referenced this pull request Nov 6, 2026
## Summary

Removed private-channel members can currently download attachments if
they know the URL. With `BUZZ_MEDIA_READ_MODE=enforce`, fresh GET, HEAD
and Range requests check current membership and readable references on
the primary database. Removed members receive 404; remaining members
retain access, and rejoining restores access. Thumbnail aliases use the
parent attachment’s permissions.

Publication checks also prevent copied private URLs from granting new
access. They cover attachments, link previews, profile pictures,
banners, group pictures and emoji. Persona creation and changed-avatar
edits, plus team and individual snapshot imports, transfer animated
posters and animations under the agent’s signer before profile
publication. Failed persona transfers preserve existing local records;
import failures appear in the results.

Authorization uses bounded queries, a two-second statement deadline and
transaction-local JIT suppression. An index on edit targets avoids broad
scans. Publications have a 512-reference limit, and capacity waits up to
500 ms before refusal.

Protection defaults off. Existing downloaded bytes cannot be recalled.
All modes stop creating immutable HTTP cache entries.

### Related issue

Related approaches: #7565, #5185, #8451 and #8444. This change evaluates
live stored references directly.

### Testing

Published head: `3d1aa7431cc495a001a0f13e95e2e422c5a77ab1`, rebased on
main `2cd40badb85de059a6916206e133da7a0a88fb2e`. Push hooks were
**SKIPPED** with explicit author approval. Current-head HTTP, full
package, PostgreSQL migration/media and profile-filter mutation checks
are pending; CI is pending. The earlier complete HTTP journey passed at
`f20b449262` (identical source tree), with its evidence attributed
separately. Earlier failing preview/report-fixture runs are preserved;
they do not count as passes.

The HTTP journey covers upload/publication, fresh GET/HEAD/Range
revocation and rejoin, emoji replacement, edited previews, persona
visibility and non-reference decoys. The migration regression forces an
index timeout, verifies no 0086 history or media tables, then proves a
migrator through the base's 0085 can restart.

Human attachment removal/rejoin testing passed on the pinned
`1f08a8e0fd2edf19dc94321f7258518149fe1bf6` trial. Native animated
persona edits and team/individual import acceptance remain incomplete.
That old-snapshot database stays intact and cannot run the reconciled
binary; a new trial needs a separate fresh database.

### Rollout prerequisites

- Build and verify the reference index before shadow mode. Small
databases can auto-migrate through one atomic 0086 migration within the
configured timeouts. It builds the index before creating the media
tables, so failure records no new migration version. Large deployments
must disable auto-migration and prebuild indexes concurrently.
- **Enforce remains blocked unless the operator can rule out SDK/CLI
attachment edits and the unverified agent-harness edit producer.**
SDK/CLI edits still omit attachment metadata. That behavior is deferred,
not fixed. Removing this blocker requires latest-effective attachment
metadata across successive edits, no restoration of removed files, and
an upload → successive edits → GET/HEAD regression.
- Validate client signer compatibility and split emoji publications
above 512 references before enforce mode.
- Complete the human workflows above in an isolated community with
enforce enabled.
- Follow the [index deployment
guide](https://github.com/block/buzz/blob/honor/private-channel-media/docs/private-media-index-deployment.md)
and [operator
guidance](https://github.com/block/buzz/blob/honor/private-channel-media/docs/operator-private-media.md)
for rollout and rollback.

The [prebuild timeout
finding](#9077 (comment))
and [avatar-transfer
finding](#9077 (comment))
are implemented and independently reviewed.

### Schema change checklist

- [x] Additive expand and code deployment; no backfill or contract
phase. Prebuild/verify before shadow under either schema deployment
path.
- [x] No SQL function, trigger, procedure, rule or foreign key added.
Signed events are untouched.
- [x] No migration mutates existing rows.
- [x] Migration and desired-state schema match. Catalog parity, target
parsing, future partition inheritance and upgrade refusal are covered.
- [x] Old/new binaries coexist for serving. Suspend community deletion
during expansion and while rolled back to a binary with the old deletion
catalog.
- [x] Keep auto-migration disabled on old binaries after schema
expansion; sqlx rejects unknown versions.
- [x] Migration lock timeout 1 s; statement timeout 5 s. Small populated
databases build inline within those budgets; large deployments use
concurrent child-index prebuild/attachment with auto-migration off.
Missing, invalid or wrong indexes fail verification.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>

This branch had an error being deployed

1 failed deployment
codex-review — bb447892 Deployed Oct 23, 2026 by ngoodger-block via Run Codex Security Review #9594
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants