Remove unfenced FI policy writers and routes

Keep old receipts readable through policy status without retaining an unfenced mutation path. Require explicit CAS for every new write.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
This commit is contained in:
Brad Seiler
2026-11-06 13:26:35 -05:00
parent 636986f1ec
commit 7b495d126f
10 changed files with 286 additions and 809 deletions

View File

@@ -42,7 +42,7 @@ 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, request form and authenticated operator. Different
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.
@@ -59,7 +59,8 @@ 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
precondition fields. Missing/deleted targets have null current fields.
`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 |
|---|---|---|
@@ -85,17 +86,27 @@ return 409 with corresponding `*_conflict` codes; generation exhaustion returns
Migration 0084 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. Apply schema first, then compatible management writers across the **whole
fleet** with `BUZZ_NIP_FI_CHANGES=false`, then explicitly enable mutations.
The gate controls both directions; reads and committed replay remain available.
Do not roll back to an unfenced management writer after the first general operation.
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.
Legacy `/nip-fi-upgrade` remains enable-only. Fresh enables on optional/nonzero
generation are refused, so an old uncommitted retry cannot reverse disable.
Already-required legacy no-ops retain their generation. Legacy GET hides general
CAS receipts with 404, and POST cannot reuse their UUIDs. Old binaries cannot hide
those receipts or fence retries; the gate-off whole-fleet rollout is mandatory.
General status can read legacy receipts, with null CAS preconditions.
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](operator-nip-fi-policy-client.md) 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

View File

@@ -0,0 +1,46 @@
# Operator NIP-FI policy client contract
Use signed POST and GET `/operator/communities/nip-fi-policy`. The former
`/operator/communities/nip-fi-upgrade` route is removed and returns 404 for GET
and POST; it no longer performs operator authentication. Deployment proxies
may independently reject a request before it reaches the relay.
For a new intent, read GET `?community_id=<UUID>&expected_host=<host>`. Copy its
`expected_policy_generation` and `expected_requires_nip_fi` into the request.
These are the only current-state field pair; the response also includes
`community_id`, `expected_host`, `mode` and `effective_required`.
Durably save the complete immutable request before its first possible dispatch:
operation UUID, community identity, expected host, authenticated actor and owner,
desired `requires_nip_fi`, both CAS preconditions, and the trusted issuer for an
enable. Disable omits the issuer. After a crash, timeout or lost response, read
GET `?operation_id=<UUID>` or replay that exact saved request with a fresh NIP-98
signature. Never reread current policy and overwrite its saved CAS fields under
the same UUID. A `policy_conflict` requires a new explicitly authorized intent
with a new UUID and freshly observed preconditions.
POST and operation-status GET return `{ "receipt": { ... }, "mode": "...",
"effective_required": true }`. Validate the authenticated operator, saved tuple,
desired action, CAS preconditions and positive accepted generation inside
`receipt`. Its `current_policy_generation` and `current_requires_nip_fi` describe
the current target, not the original intent. They are null after target deletion.
`pending`, `applied`, `superseded` and `unavailable` have the meanings in the
[operator contract](community-auth-upgrades.md#readback-and-response).
Historical acceptance never certifies current policy or fleet-wide socket closure.
No response reports `active`.
Previously accepted enable receipts remain readable through operation-status
GET, with desired=true and null CAS preconditions. Recover them as history; do
not invent preconditions or convert them into a new write. Reusing their UUID
for a new fenced request returns `idempotency_conflict`, even after deletion.
Treat only the structured 404 `operation_not_found` from the supported endpoint
as an absent receipt. A 404 from the removed route is not evidence that an
operation was never accepted.
Ship migrated callers with mutation dispatch paused before rolling out the
route removal. Close the old gate on all old binaries and keep the new gate
closed on new binaries. Apply additive schema, complete the whole-fleet rollout,
then open `BUZZ_NIP_FI_CHANGES` and resume supported callers. See
[rollout](community-auth-upgrades.md#schema-and-rollout). An uncertain operation
from the old caller needs explicit reconciliation; a missing receipt is not
permission to synthesize a replacement policy write.

View File

@@ -5,8 +5,11 @@ stored requirement through the deployment operator API. The command needs the
configured `RELAY_OPERATOR_API_ORIGIN` and an allowlisted operator signing key
in a protected file. It sends one signed request and never retries automatically.
Before changing policy, apply the schema and roll compatible management writers
across the whole fleet with `BUZZ_NIP_FI_CHANGES=false`. Then explicitly
Before rolling, close `BUZZ_NIP_FI_UPGRADES_ENABLED` on every old relay and
keep `BUZZ_NIP_FI_CHANGES=false` on new relays. Pause callers and upgrade them
for the [supported API](operator-nip-fi-policy-client.md) before deploying this
relay version. Apply the schema and roll compatible management writers across
the whole fleet. Then explicitly
enable that gate, which controls both enable and disable. Enable additionally
requires the deployment-selected issuer to be trusted and already bound to the
community. The active community must have exactly one owner; the authenticated
@@ -71,5 +74,7 @@ A racing sweep may close one last non-FI socket; it can reconnect under optional
policy. FI-admitted sockets retain their finite deadline. Re-enable repeats the
optional-lease drain and existing periodic revalidation.
The `nip-fi-upgrade enable/status` command targets the legacy enable-only endpoint.
Use the general command for new operations; legacy GET hides general CAS receipts.
The old `nip-fi-upgrade` command and endpoint are removed. Requests to that
route now return 404. Recover historical receipts with `nip-fi-policy status
--operation-id`; their original null CAS preconditions remain visible. Never
reuse a historical operation UUID for a new intent.