From 446e9e0762a4bfe4c74e138eacf44d7d9d657ca9 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 15:07:08 -0500 Subject: [PATCH] Allow operators to set initial NIP-FI policy on create (#9211) Trusted operators can choose `initial_requires_nip_fi` on create-only community provisioning. True commits required policy (generation 1), validated issuer bindings and the owner with the new community in one transaction. Omitted, null or false preserves optional creation; a non-null field in legacy ensure mode returns coded 400. Missing effective bindings refuses required insertion without leaving a row. The response reports policy read from the stored row. Same-owner create-only replay now accurately reports `existed` instead of `created`; it stays successful and read-only, including after policy disable or issuer-config changes. Different-owner conflict, legacy ensure, bootstrap and the operator authorization gate retain their behavior. This adds no schema changes or server-wide default flag. Upgrade the whole serving relay fleet before callers request true. An old writer may ignore the field and insert an optional row: response checks detect it but cannot undo it, and later lookup remains read-only. Turn caller targeting off before relay rollback. Mode off stores policy but bypasses enforcement, independently of the upgrade gate. ### Related issue Replaces closed #9172. The separate lifecycle API remains in #9176. ### Validation Draft head: `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Independent source review covers this head. Formatting, diff sanity, Clippy, build, all 14 signed HTTP scenarios and full local `just ci` pass at this head. GitHub reports 59 successful and 38 skipped checks, with none pending or failing in the collected snapshot. At parent `b9d9423a6058387f42c310f5a7e4dbe0c8d8b40f`, all 1,978 package tests and 1,346 PostgreSQL tests passed (PostgreSQL concurrency 2). Four deliberate mutations were detected and restored. Clippy then found seven needless borrows in the new HTTP test; this head removes those borrows only. Earlier validation passed 1,344/1,346 PostgreSQL tests at concurrency 8; the replay assertion was corrected and the timeout disappeared at concurrency 2. Failed-run evidence is retained. At this head, the first mandatory push-hook attempt failed all nine boot-lifecycle tests on child startup deadlines. A subsequent serial run passed all nine (seven PostgreSQL-only cases skipped), with unchanged deadlines and source. The first diagnostic was interrupted during compilation without a result; all evidence is retained. A normal-hook retry was stopped when the author explicitly authorized one push with `--no-verify` for this exact commit. That exception skips local pre-push hooks for publication only; failed hooks and remaining validation still gate readiness. Explicit human runtime confirmation remains a separate acceptance step; agents have not attested it. Generated with Codex Validation update at the same draft head: Clippy, build and all 14 signed HTTP workflow scenarios passed, including atomic required creation, stored-policy replay after disable without a current binding, and refusal without leaving a row. The first live harness run stopped after 12 checks because its SQL helper fetched rows after an UPDATE without RETURNING; that harness query was corrected and the complete workflow passed. Relay source stayed unchanged. The full local `just ci` run failed in `mobile-check`: Dart formatting reported 266 changes among 1,068 files, with unresolved `flutter_lints` configuration warnings. The format check used `--output=none`; the worktree remains clean. Repository validation is therefore not green. That failure is retained as historical evidence; see the successful setup-corrected rerun below. Explicit human runtime confirmation remains separate. Final local validation: after `just mobile-install`, `mobile-check` passed without formatting edits, and full `just ci` completed successfully at `bf16a1e0aba102c3be86f9621bd53d2cae98661c`. Tracked files remained clean after installation and after the complete run. This confirms the earlier mobile formatting failure resulted from missing worktree dependencies. All failed runs remain preserved. GitHub's current ready-for-review state is preserved; no agent added `buzz-review-completed` or attested human runtime acceptance. --------- Signed-off-by: Brad Seiler Co-authored-by: Honor Co-authored-by: Honor --- crates/buzz-db/src/error.rs | 5 + crates/buzz-db/src/store/community.rs | 73 ++++++- crates/buzz-db/src/store/nip_fi_binding.rs | 93 ++++++++ crates/buzz-relay/src/api/operator.rs | 13 ++ .../initial_policy_postgres_tests.rs | 205 ++++++++++++++++++ .../src/handlers/community_provisioning.rs | 28 ++- docs/operator-nip-fi-bindings.md | 30 +++ 7 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs diff --git a/crates/buzz-db/src/error.rs b/crates/buzz-db/src/error.rs index 56cf748c34..9ce2cb0405 100644 --- a/crates/buzz-db/src/error.rs +++ b/crates/buzz-db/src/error.rs @@ -42,6 +42,10 @@ pub enum DbError { #[error("access denied: {0}")] AccessDenied(String), + /// Required initial policy cannot be created without issuer bindings. + #[error("required community creation needs NIP-FI issuer bindings")] + NipFiBindingRequired, + /// Invitation admission or minting was denied by authoritative restrictions. #[error("invitation principal is restricted")] InviteRestricted, @@ -176,6 +180,7 @@ impl DbError { E::InvalidData(_) => "invalid_data", E::NotFound(_) | E::ChannelNotFound(_) | E::MemberNotFound(_) => "not_found", E::AccessDenied(_) => "access_denied", + E::NipFiBindingRequired => "nip_fi_binding_required", _ => "other", }; kind.to_owned() diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index b0c35890df..50d1b20f45 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -41,6 +41,10 @@ pub struct CreatedCommunityRecord { pub id: CommunityId, /// Normalized host stored for the community. pub host: String, + /// Whether this transaction inserted the community. + pub created: bool, + /// Stored policy read from the inserted or existing row. + pub requires_nip_fi: bool, } /// Result of atomically creating a community with its initial owner. @@ -611,7 +615,9 @@ impl Db { /// Like [`Db::create_community_with_owner`], binding `nip_fi_issuers` to /// the new community in the same transaction. Identical retries return the - /// original record without touching its bindings. + /// original record without touching its bindings. Operator tenant creation + /// must use [`Db::create_community_with_owner_and_nip_fi_policy`] to apply + /// the configured creation requirement. #[datastore_span( name = "create_community_with_owner_and_nip_fi_issuers", system = "postgresql" @@ -622,6 +628,31 @@ impl Db { owner_pubkey: &str, nip_fi_issuers: &[String], relay_keypair: &nostr::Keys, + ) -> Result> { + self.create_community_with_owner_and_nip_fi_policy( + normalized_host, + owner_pubkey, + nip_fi_issuers, + false, + relay_keypair, + ) + .await + } + + /// Atomically create a community, owner, issuer bindings and initial NIP-FI + /// policy (generation 1 when required, otherwise 0). Identical retries keep + /// the original policy and bindings. Required creation needs issuers. + #[datastore_span( + name = "create_community_with_owner_and_nip_fi_policy", + system = "postgresql" + )] + pub async fn create_community_with_owner_and_nip_fi_policy( + &self, + normalized_host: &str, + owner_pubkey: &str, + nip_fi_issuers: &[String], + requires_nip_fi: bool, + relay_keypair: &nostr::Keys, ) -> Result> { use relay_members::MembershipWrite; let owner_pubkey = owner_pubkey.to_ascii_lowercase(); @@ -644,17 +675,21 @@ impl Db { let row = sqlx::query( r#" - INSERT INTO communities (host) - VALUES ($1) + INSERT INTO communities (host, requires_nip_fi, policy_generation) + VALUES ($1, $2, CASE WHEN $2 THEN 1 ELSE 0 END) ON CONFLICT (lower(host)) DO NOTHING - RETURNING id, host + RETURNING id, host, requires_nip_fi "#, ) .bind(normalized_host) + .bind(requires_nip_fi) .fetch_optional(&mut *tx) .await?; if let Some(row) = row { + if requires_nip_fi && nip_fi_issuers.is_empty() { + return Err(DbError::NipFiBindingRequired); + } let id: Uuid = row.try_get("id")?; let host: String = row.try_get("host")?; @@ -694,12 +729,14 @@ impl Db { let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { id: CommunityId::from_uuid(id), host, + created: true, + requires_nip_fi: row.try_get("requires_nip_fi")?, }); relay_members::commit_membership_write(tx, record, true, relay_keypair).await } else { let existing = sqlx::query( r#" - SELECT c.id, c.host + SELECT c.id, c.host, c.requires_nip_fi FROM communities c JOIN relay_members rm ON rm.community_id = c.id WHERE lower(c.host) = lower($1) @@ -723,6 +760,8 @@ impl Db { let record = CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { id: CommunityId::from_uuid(existing.try_get("id")?), host: existing.try_get("host")?, + created: false, + requires_nip_fi: existing.try_get("requires_nip_fi")?, }); tx.commit().await?; Ok(MembershipWrite::unchanged(record)) @@ -1152,7 +1191,10 @@ mod postgres_tests { .expect("same-owner retry"); assert_eq!( retry, - CreateCommunityWithOwnerResult::Created(created.clone()), + CreateCommunityWithOwnerResult::Created(CreatedCommunityRecord { + created: false, + ..created.clone() + }), "retry returns the original row" ); @@ -1629,8 +1671,23 @@ mod postgres_tests { let first = first.expect("first concurrent create"); let second = second.expect("second concurrent create"); - assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); - assert_eq!(first, second, "conflict loser re-reads the winning row"); + let ( + CreateCommunityWithOwnerResult::Created(first), + CreateCommunityWithOwnerResult::Created(second), + ) = (first, second) + else { + panic!("both concurrent callers must receive the stored community"); + }; + assert_eq!( + first.id, second.id, + "conflict loser re-reads the winning row" + ); + assert_eq!(first.host, second.host); + assert_eq!(first.requires_nip_fi, second.requires_nip_fi); + assert_ne!( + first.created, second.created, + "exactly one caller inserted the row" + ); } #[tokio::test] diff --git a/crates/buzz-db/src/store/nip_fi_binding.rs b/crates/buzz-db/src/store/nip_fi_binding.rs index ffd25cba96..46dd231f34 100644 --- a/crates/buzz-db/src/store/nip_fi_binding.rs +++ b/crates/buzz-db/src/store/nip_fi_binding.rs @@ -613,6 +613,99 @@ mod postgres_tests { assert!(!community_exists(&db, &bootstrap).await); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_is_atomic_and_replays_preserve_current_state() { + let db = setup_db().await; + let keys = nostr::Keys::generate(); + for required in [false, true] { + let host = unique_host("initial-policy"); + let owner = nostr::Keys::generate().public_key().to_hex(); + let write = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &owner, + &issuers(), + required, + &keys, + ) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(record) = write.outcome else { + panic!("not created") + }; + assert!(record.created); + assert_eq!(record.requires_nip_fi, required); + let before = db.community_auth_policy(record.id).await.unwrap().unwrap(); + assert_eq!(before.requires_nip_fi, required); + assert_eq!(before.policy_generation, i64::from(required)); + assert_eq!(bound(&db, &host).await, Some(issuers())); + let owners = db.list_communities_owned_by(&owner).await.unwrap(); + assert_eq!(owners.communities[0].id, record.id); + // Changed policy and issuer configuration never rewrite a committed row. + let changed = ["https://other.example".to_owned()]; + for bindings in [&[][..], changed.as_slice()] { + let retry = db + .create_community_with_owner_and_nip_fi_policy( + &host, &owner, bindings, !required, &keys, + ) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(retry) = retry.outcome else { + panic!("not replayed") + }; + assert!(!retry.created); + assert_eq!(retry.requires_nip_fi, required); + assert_eq!( + db.community_auth_policy(record.id).await.unwrap().unwrap(), + before + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + } + // Model an operator disable. A later true replay must report the stored false. + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(&db.pool).await.unwrap(); + let disabled = db.community_auth_policy(record.id).await.unwrap().unwrap(); + let replay = db + .create_community_with_owner_and_nip_fi_policy(&host, &owner, &[], true, &keys) + .await + .unwrap(); + let CreateCommunityWithOwnerResult::Created(replay) = replay.outcome else { + panic!("not replayed") + }; + assert!(!replay.created); + assert!(!replay.requires_nip_fi); + assert_eq!( + db.community_auth_policy(record.id).await.unwrap().unwrap(), + disabled + ); + assert_eq!(bound(&db, &host).await, Some(issuers())); + let conflict = db + .create_community_with_owner_and_nip_fi_policy( + &host, + &nostr::Keys::generate().public_key().to_hex(), + &[], + true, + &keys, + ) + .await + .unwrap(); + assert_eq!(conflict.outcome, CreateCommunityWithOwnerResult::HostExists); + } + for bindings in [vec![], vec![String::new()]] { + let host = unique_host("refused-initial-policy"); + let refusal = db + .create_community_with_owner_and_nip_fi_policy(&host, OWNER, &bindings, true, &keys) + .await; + if bindings.is_empty() { + assert!(matches!(refusal, Err(crate::DbError::NipFiBindingRequired))); + } else { + assert!(refusal.is_err()); + } + assert!(!community_exists(&db, &host).await); + } + } + // Deletion takes its exclusive lock, then the community row. A bind that // held the row while waiting for that lock would deadlock with it. // Mutation: locking the row before `guard_transaction` in diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 755b41f240..92c57e188c 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -423,6 +423,18 @@ pub async fn provision_community( Err(msg) if msg.starts_with("actor not authorized") => { Err(api_error(StatusCode::FORBIDDEN, &msg)) } + Err(msg) if msg.starts_with("nip_fi_binding_required:") => Err(coded_api_error( + StatusCode::BAD_REQUEST, + "nip_fi_binding_required", + &msg, + )), + Err(msg) if msg.starts_with("initial_policy_requires_create_only:") => { + Err(coded_api_error( + StatusCode::BAD_REQUEST, + "initial_policy_requires_create_only", + &msg, + )) + } Err(msg) if msg.starts_with("limit_reached:") => { Err(coded_api_error(StatusCode::CONFLICT, "limit_reached", &msg)) } @@ -1387,6 +1399,7 @@ pub async fn no_store_upgrade_response( #[cfg(test)] mod postgres_tests { + mod initial_policy_postgres_tests; use std::{ collections::HashSet, sync::{Arc, Mutex}, diff --git a/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs new file mode 100644 index 0000000000..d0dc9ff1ba --- /dev/null +++ b/crates/buzz-relay/src/api/operator/postgres_tests/initial_policy_postgres_tests.rs @@ -0,0 +1,205 @@ +mod postgres_tests { + use super::super::*; + + async fn create( + state: &Arc, + operator: &Keys, + owner: &Keys, + host: &str, + create_only: bool, + policy: Option, + ) -> axum::response::Response { + let mut body = serde_json::json!({"host":host,"initial_owner_pubkey":owner.public_key().to_hex(),"create_only":create_only}); + if let Some(policy) = policy { + body["initial_requires_nip_fi"] = policy; + } + signed_operator_request( + state.clone(), + operator, + "POST", + "/operator/communities", + Some(body.to_string()), + ) + .await + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn initial_policy_and_same_owner_replay_journey() { + let operator = Keys::generate(); + let (binding, reserved, control) = reserved_host_policy(); + let mut state = + operator_test_state_with_policy(std::slice::from_ref(&operator), Some(binding)) + .await + .unwrap(); + let owners: Vec<_> = (0..4).map(|_| Keys::generate()).collect(); + let owner = &owners[3]; + assert!(!state.config.nip_fi_upgrades_enabled); + for (index, policy) in [ + None, + Some(Value::Null), + Some(Value::Bool(false)), + Some(Value::Bool(true)), + ] + .into_iter() + .enumerate() + { + let host = control.replacen("acme.", &format!("policy-{index}."), 1); + let required = policy == Some(Value::Bool(true)); + let owner = &owners[index]; + let response = create(&state, &operator, owner, &host, true, policy).await; + assert!(response.status().is_success()); + let body = read_json(response).await; + assert_eq!(body["status"], "created"); + assert_eq!(body["requires_nip_fi"], required); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + let stored = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.requires_nip_fi, required); + assert_eq!(stored.policy_generation, i64::from(required)); + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(!required)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], required); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + stored + ); + } + for value in [false, true] { + let response = create( + &state, + &operator, + owner, + &control, + false, + Some(Value::Bool(value)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + read_json(response).await["code"], + "initial_policy_requires_create_only" + ); + } + let refused = create( + &state, + &operator, + owner, + &reserved, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refused.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refused).await["code"], "reserved_host"); + let outside = format!("outside-{}.example", Uuid::new_v4().simple()); + let refusal = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(refusal.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(refusal).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let host = control.replacen("acme.", "policy-3.", 1); + let record = state + .db + .lookup_community_by_host(&host) + .await + .unwrap() + .unwrap(); + sqlx::query("UPDATE communities SET requires_nip_fi = false, policy_generation = policy_generation + 1 WHERE id = $1") + .bind(record.id.as_uuid()).execute(state.db.pool()).await.unwrap(); + let disabled = state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(); + Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).nip_fi_provision_binding = + None; + let response = create( + &state, + &operator, + owner, + &host, + true, + Some(Value::Bool(true)), + ) + .await; + assert!(response.status().is_success()); + let replay = read_json(response).await; + assert_eq!(replay["status"], "existed"); + assert_eq!(replay["requires_nip_fi"], false); + assert_eq!( + state + .db + .community_auth_policy(record.id) + .await + .unwrap() + .unwrap(), + disabled + ); + let outside = format!("unbound-{}.example", Uuid::new_v4().simple()); + let response = create( + &state, + &operator, + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(read_json(response).await["code"], "nip_fi_binding_required"); + assert!(state + .db + .lookup_community_by_host(&outside) + .await + .unwrap() + .is_none()); + let unauthorized = create( + &state, + &Keys::generate(), + owner, + &outside, + true, + Some(Value::Bool(true)), + ) + .await; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 377783abec..e99f50b8f2 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -48,6 +48,9 @@ pub struct ProvisionCommunityRequest { /// host instead of converging or rotating ownership. #[serde(default)] pub create_only: bool, + /// Initial policy for an atomic create only; absent/null preserves legacy behavior. + #[serde(default)] + pub initial_requires_nip_fi: Option, } /// JSON response from `POST /operator/communities`. @@ -63,6 +66,9 @@ pub struct ProvisionCommunityResponse { /// Echoes the validated owner pubkey when an owner bootstrap/rotation ran. #[serde(skip_serializing_if = "Option::is_none")] pub owner_pubkey: Option, + /// Current stored policy, present for the create-only writer. + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_nip_fi: Option, } pub(crate) fn validate_pubkey_hex(value: &str) -> Option { @@ -253,6 +259,12 @@ pub async fn provision_community( return Err("actor not authorized: not a relay operator".to_string()); } + if request.initial_requires_nip_fi.is_some() && !request.create_only { + return Err( + "initial_policy_requires_create_only: initial_requires_nip_fi requires create_only" + .to_owned(), + ); + } validate_host(&request.host)?; let nip_fi_policy = state.config.nip_fi_provision_binding.as_ref(); // Unbound either way: `issuers_for_new_host` binds nothing on a reserved host. @@ -278,14 +290,18 @@ pub async fn provision_community( })?; let write = state .db - .create_community_with_owner_and_nip_fi_issuers( + .create_community_with_owner_and_nip_fi_policy( &request.host, owner_hex, nip_fi_issuers, + request.initial_requires_nip_fi.unwrap_or(false), &state.relay_keypair, ) .await - .map_err(|e| format!("failed to create community: {e}"))?; + .map_err(|e| match e { + buzz_db::DbError::NipFiBindingRequired => format!("nip_fi_binding_required: {e}"), + _ => format!("failed to create community: {e}"), + })?; let record = match write.outcome { buzz_db::CreateCommunityWithOwnerResult::Created(record) => record, buzz_db::CreateCommunityWithOwnerResult::HostExists => { @@ -301,7 +317,9 @@ pub async fn provision_community( community = %record.id, host = %record.host, owner = %owner_hex, - "community created via operator endpoint" + created = record.created, + requires_nip_fi = record.requires_nip_fi, + "community provisioned via operator endpoint" ); record_binding_creation( state.nip_fi_resolver.as_deref(), @@ -313,8 +331,9 @@ pub async fn provision_community( return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), host: record.host, - status: "created", + status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: Some(record.requires_nip_fi), }); } @@ -369,6 +388,7 @@ pub async fn provision_community( host: record.host, status: if record.created { "created" } else { "existed" }, owner_pubkey: initial_owner, + requires_nip_fi: None, }) } diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index 8afecc7a10..683d82fef3 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -65,6 +65,36 @@ they end at their own session deadline. Alert on `time() - buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds > 120` and on `absent(buzz_nip_fi_binding_snapshot_loaded_timestamp_seconds)`. +## Initial policy on operator creation + +A trusted NIP-98 operator may send `initial_requires_nip_fi: true` with +`create_only: true` to `POST /operator/communities`. The community, owner, +issuer bindings and required policy (generation 1) commit in one transaction. +Omitted, null or false creates an optional community at generation 0. A non-null +policy field without `create_only: true` returns 400 +`initial_policy_requires_create_only`. + +Required insertion needs an effective provision binding for the host; otherwise +400 `nip_fi_binding_required` leaves no row. Reserved hosts retain the existing +`reserved_host` refusal. The successful response includes the stored +`requires_nip_fi` and `status: "created"` for insertion or `"existed"` for a +same-owner replay. A replay changes neither policy nor bindings, including after +an operator disable or a change in issuer configuration. A different owner +retains the existing conflict. Legacy ensure and deployment bootstrap remain +optional and do not accept an explicit initial policy. + +This policy is independent of `BUZZ_NIP_FI_UPGRADES_ENABLED`. Mode `off` stores +it but bypasses enforcement. The production creation paths are operator +create-only, legacy operator ensure, and deployment bootstrap; only create-only +accepts this choice. + +Upgrade the **whole serving relay fleet** before callers request true. Older +writers may ignore the unknown field and insert an optional row. Checking the +created response for stored true detects that incompatible writer, but cannot +undo its insertion; a later existing-row lookup remains read-only. Turn caller +targeting off before rolling back the relay. Turning it off affects future +insertions only; it does not clear existing policy. + ## Commands ```sh