## Summary Operators can enable, disable and inspect a community's stored NIP-FI requirement through signed POST/GET `/operator/communities/nip-fi-policy` and `buzz-admin nip-fi-policy`. This replaces the old enable-only endpoint and drops the `nip-fi-upgrade` command added earlier in this PR, along with obsolete compatibility code. Each saved intent binds its desired state and observed generation/state. Every accepted new intent advances the generation, including same-state changes. Committed retries recover history without reapplying policy; stale requests are refused. Current status exposes one pair of reusable `expected_*` preconditions. Historical receipts remain readable after policy changes or target deletion, including old rows with null preconditions. Their UUIDs cannot be reused for new requests. Migration 0085 stays additive; main’s default-channel recovery migration retains version 0084. It preserves old receipt rows and INSERT compatibility. Operator instructions cover use, recovery and client migration: [usage guide](33a1c0bb87/docs/operator-nip-fi-upgrade.md), [client contract](33a1c0bb87/docs/operator-nip-fi-policy-client.md). ### Rollout and containment The old `/operator/communities/nip-fi-upgrade` route is removed; GET and POST return 404 without its old auth handler. A pending sponsorship client still targets that route. Migrate that client to the supported API and deploy it with mutation dispatch paused before this relay version reaches its fleet. Its complete CAS preconditions must be durably saved before first dispatch and reused unchanged after a crash or lost response. This is a pending integration dependency, not a claim that the caller is deployed. Close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay. Keep `BUZZ_NIP_FI_CHANGES=false` on new relays. Apply schema first and finish the whole-fleet rollout before opening the new gate and resuming migrated clients. Before opening the new gate, verify the deployed artifact SHA on every pod and probe each pod directly: the old route must return 404, and a fresh, well-formed, signed policy request with a new UUID must return 503 `upgrades_disabled`. A committed retry is not a gate probe. Old pods with the old gate open can still accept unfenced enables. Do not roll back to those writers after a policy operation. The old variable is no longer read by this version; the new gate defaults off and controls both directions. Read accepted intent in `receipt`, current preconditions/effective policy through status, and effective discovery through `/info`. Enable stays `pending` because receipts cannot certify fleet-wide socket closure. Under global `enforce`, stored disable leaves FI effectively required. Closing the gate preserves receipt recovery; use a fresh CAS disable or existing archive controls for containment. Preserve uncertain requests and their UUIDs. This PR performs no live state change or deployment. ### Related issue N/A ### Testing Published repair head: `33a1c0bb87a6fa10212eb4ec60788e24189627e2`, incorporating main `39d5159caf`. The Git policy guards now apply only to matched paths. Unknown GET/POST paths on relays without a web directory return 404 instead of inheriting the internal endpoint’s 403. The matched internal endpoint retains its localhost restriction and 1 MB body limit, including its method fallback. The signed removed-route 404 assertion is unchanged. Main’s migration 0084 is unchanged; the additive policy migration is now 0085 with identical SQL. Its populated-receipt upgrade test starts at schema 84. Formatting and commit hooks pass; independent source review has no findings. At this exact head, formatting, all-target Clippy (`-D warnings`, relay/db/admin/pubsub), and test compilation (relay/db/admin, `--lib --tests --no-run`) pass with exit 0. The full native PostgreSQL lane passes: 1,373/1,373 tests, exit 0, including the unchanged signed removed-route 404 assertion, fresh-database migration and populated receipt upgrade from main schema 84 to 85. The first full package run used an uninitialized private database and stopped with two missing-table errors; that failed attempt is retained. After schema initialization and verification, the corrected full package rerun passes: 2,041/2,041 tests, exit 0 (1,396 skipped by unit selection). The separate PostgreSQL lane skips 2,064 tests by its selection. Both full runs use the exact published repair head. Hosted CI remains pending. The new router test reproduced the old failure with the exact localhost-only response body; the new production-router regression also passes in the corrected package run. Four individual guard mutations are running serially; their assertion-failure receipts and the restored final green run remain pending. Brad authorized one `git push --no-verify` of this repair conditional on passing lint and compilation. Those gates passed; the push exited 0 and both the remote branch and GitHub PR SHA were verified as `33a1c0bb87a6fa10212eb4ec60788e24189627e2`. The normal publication attempt was stopped before completion and its log is retained. This exception leaves remaining tests and acceptance requirements outstanding. Historical evidence below applies to the earlier published head, not the published repair. Published head: `d31793ccbcdfd748361bcd559358915862b5ed57`. This integrates main's missing `ChannelEvent.received_at` fixture correction and corrects the newly integrated initial-policy test to use `nip_fi_changes`, with matching documentation. Independent source review has no open findings. At this exact head, all-target Clippy with `-D warnings` passes for buzz-relay, buzz-db, buzz-admin and buzz-pubsub; independent Clippy and formatting checks also passed. The normal push hooks failed (push exit 1): - Rust: `buzz-acp` recovery test hit its 15-second test deadline. Nextest completed 844/1101 tests: 843 passed, 1 failed, 3 skipped; 257 were not run. - Tauri: `buzz-terminal` login-shell test received no child output within its 10-second test deadline; 26 passed, 1 failed. - Mobile: gallery test `2 trailing images match carousel at 208.0, scale 1.0` expected no loading placeholders and found two; 4,524 passed, 60 skipped, 1 failed. These are two test-level deadline failures and one assertion failure, not compiler errors. The failing source paths are unchanged from main `6c3ffd160b`; no baseline reproduction or flaky classification is claimed. Desktop JS checks/tests/typecheck, branch-skew and file-size checks passed. Full hooks are not reported as passing. The first hosted PostgreSQL run atd31793ccfailed the removed-route assertion (expected 404, got 403); the router repair above addresses that failure. That hosted run’s Rust unit, Tauri and mobile jobs passed. Historicald31793ccpublication exception: Brad explicitly authorized a single `git push --no-verify` for this head on 2026-11-06 after the failed normal-hook run. That push exited 0 and its remote SHA was verified. Failed-hook evidence is retained. The earlier `cf2c9ba599` publication also used an explicit exception. Neither exception waives outstanding validation, review or human acceptance before merge. Historical evidence at `5b2eb6f09366b8a8f480a5fc61e8e5368f62302d`: 2,006 affected-package tests and 32 disposable-relay checks passed. These predate the latest main merge and are not final-head evidence. Earlier native PostgreSQL and separate `just ci` / `just test` attempts were incomplete, interrupted or failed and are not passing results. No staging run or human runtime confirmation is claimed. This PR is not a draft and is ready for review; readiness does not attest human runtime confirmation. Generated with Codex --------- Signed-off-by: Brad Seiler <seiler@squareup.com> Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
8.7 KiB
Community NIP-FI policy operator contract
The operator plane manages a stored community requirement independently of the global FI mode. See operator usage. Receipts prove durable acceptance, never fleet-wide socket closure or current authorization.
Operator boundary
POST/GET /operator/communities/nip-fi-policy require an allowlisted operator's
NIP-98 signature bound to RELAY_OPERATOR_API_ORIGIN, method and URL (including
the GET query), and POST body hash. Each attempt needs a fresh signature. These
routes extend provisioning outside tenant event ingest; ordinary membership
credentials cannot invoke them. All responses use Cache-Control: no-store.
The operator mediates an authenticated actor's registered key. The relay verifies
the operator and stored owner; actor_pubkey is the mediator's claimed actor,
not an independent user credential. Never accept a caller-selected actor as identity.
Generation storage
communities.requires_nip_fi defaults false; policy_generation defaults zero.
Every new general operation advances the generation, even a same-state intent.
Replay never increments. Generation exhaustion is refused. The historical
community_auth_upgrades table stores acceptance receipts for both directions.
Receipts survive target deletion and are not tenant serving data.
Enable and disable
POST contains operation_id, community_id, canonical expected_host,
actor_pubkey, expected_owner_pubkey, desired requires_nip_fi, and mandatory
expected_policy_generation plus expected_requires_nip_fi. UUIDs must be non-nil,
keys lowercase hex, generations nonnegative; unknown fields are rejected.
Enable requires expected_issuer; disable must omit it.
Read current preconditions with GET ?community_id=<UUID>&expected_host=<host>.
Both generation and stored state must still match under the writer row lock.
A mismatch returns 409 policy_conflict without mutation or receipt. Re-read
and use a new UUID/request for revised intent. On a lost response, preserve the
original complete file/UUID and recover or retry it with a fresh signature.
Acceptance serializes UUID reuse, then locks deletion-shared → community
FOR NO KEY UPDATE → exclusive host lease, preserving lifecycle/deletion-intent,
exact host, single owner and actor==owner checks and community write admission.
Acceptance and receipt commit atomically. UUID identity binds the full tuple,
action, both preconditions and authenticated operator. Different
meaning under a committed UUID returns 409 idempotency_conflict. Identical
committed replay recovers history before mutable preflight and never writes policy.
Enable requires a single deployment-selected authorized issuer in
BUZZ_NIP_FI_PROVISION_BINDING, an applicable registry policy and exact existing
community binding. It locks the binding against unbind and adds no trust.
Disable does not depend on issuer availability and does not alter bindings.
Every optional→required transition, including re-enable, excludes new host leases
and drains prior optional leases for 1.1 seconds before committing.
Readback and response
GET ?operation_id=<UUID> returns only the originating operator's receipt.
Mixed query forms are invalid. The response separates receipt (original tuple,
accepted generation/action/preconditions and current target generation/flag) from
mode and effective_required. Current-state reads also print exact expected
expected_policy_generation and expected_requires_nip_fi fields, without a
duplicate unprefixed pair. Missing/deleted targets have null current fields.
| Receipt state | Meaning | CLI exit |
|---|---|---|
applied |
Current stored disable | 0 |
pending |
Current accepted enable; closure uncertified | 10 |
superseded |
Later generation or state differs | 11 |
unavailable |
Target/lifecycle/host or required issuer trust unavailable | 12 |
pending_reasons includes connection_closure_unverified for enable. Neither
time, local counters nor a successful sweep certify fleet completion. No response
reports active. History is not a current authorization credential; ownership
changes do not destroy the original operator's recovery authority.
Invalid requests return 400; operator auth uses existing 401/403 errors. Missing
community/operation returns 404. Host, owner, lifecycle, issuer and policy conflicts
return 409 with corresponding *_conflict codes; generation exhaustion returns
409 policy_generation_exhausted. New mutations behind a closed gate return
503 upgrades_disabled; unavailable authoritative DB/trust returns
503 authorization_unavailable. Request/transport/response errors exit 5.
Schema and rollout
Migration 0085 adds desired action and nullable CAS preconditions to existing
receipts. True/default and null preconditions preserve old receipt meaning and
old INSERT compatibility. No data backfill, trigger, function or foreign key is
added. Historical rows with null CAS preconditions remain readable through
policy status, including after target deletion. A new fenced request cannot reuse
such a row's UUID: null historical preconditions differ from the mandatory new
preconditions, so it returns idempotency_conflict without mutation.
The old /operator/communities/nip-fi-upgrade endpoint and the nip-fi-upgrade
admin command have been removed. Its GET and POST requests receive 404; use
nip-fi-policy for both changes and receipt recovery. Upgrade existing clients
for the policy client contract before this
relay version reaches their fleet, with their mutations paused until rollout ends.
No client may guess CAS fields or refresh a saved request during recovery.
Before rolling, close BUZZ_NIP_FI_UPGRADES_ENABLED on every old relay and keep
BUZZ_NIP_FI_CHANGES=false on new relays. Apply schema first, roll the whole
fleet to the supported policy writer, then explicitly enable the new gate and
resume migrated clients. Old binaries with their old gate open can still accept
unfenced enables and reverse a disable; a new-name setting alone cannot close
that old gate. Retire the old setting after the last old binary is gone.
Reads and committed replay remain available on the supported API with its gate
closed. Do not roll back to a writer that exposes unfenced enable after any
policy operation. This removal does not erase receipts or rewrite schema.
Request enforcement and discovery
off ignores stored requirements; shadow enforces stored true; enforce is a
global requirement floor; deny_protected denies protected traffic. Report
effective_required alongside stored state so disable under enforce and enable
under off cannot be mistaken for effective changes. Evaluating policy lookups
fail closed on errors; off does not require an FI policy read.
Host policy comes from the writer, bound to community identity and lifecycle.
Optional cache leases last at most one second; enable excludes refills and drains
them. Fail-closed cache ordering may retain old required state for the remaining
lease after disable. It cannot renew that stale lease. /info advertises effective
requires_nip_fi without exposing operator receipts.
Existing connections
The periodic revalidator reads stored policy without row-locking transactions. After disable it stops requesting required-policy closes; a concurrent sweep may request one last non-FI close from its earlier snapshot. That client reconnects under optional policy. FI-admitted sockets retain their finite lifetime and then reconnect under current policy. Disable does not reopen or force-logout sessions. Re-enable repeats the optional-lease drain; non-FI root/audio sockets close through existing revalidation. Valid FI-admitted sockets retain existing rules.
Mistaken acceptance and containment
Stop an uncertain initiating caller and preserve its operation evidence. Read current policy before deciding whether to issue a new explicit CAS disable. A timeout is not permission to discard the original request or edit SQL. Disable is a supported operator action; acceptance is no longer monotonic. Disable removes the required-policy block on last-issuer removal; the separate last-binding guard still applies. The operator unbind command keeps the last issuer for bindable hosts. Re-enable must recheck trust and the exact binding. Removing trust never implicitly clears stored policy.
Close the mutation gate to stop new changes while keeping recovery available. Use established archive/lifecycle containment or repair FI trust as appropriate; never broaden trust to make a probe pass. Global off suspends FI everywhere while preserving stored policy and receipts; it is not per-community disable. Binary rollback does not undo acceptance and must not restore unfenced writers. No live mutation or deployment is implied by this procedure.