feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec - #7288
feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec#7288wpfleger96 wants to merge 15 commits into
Conversation
Brings buzz-media/src/auth.rs, buzz-relay/src/api/media.rs, and the desktop token minting into full compliance with NIP-FI §kind-24242. Changes: buzz-media/src/auth.rs: - Add BlossomStrictness enum (Strict | Permissive). Strict applies full NIP-FI rules; Permissive preserves pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15]. - Rewrite verify_blossom_auth_event_for_verb with count-based cardinality tracking: exactly one t/expiration/server (Strict), at most one x. - Strict: mandatory server tag on all proofs (upload + read); absent or mismatched -> evidence_rejected (ServerMismatch). - Strict: 60s proof window (now - created_at <= 60s, expiration <= created_at + 60s). - Permissive: 3600s window, optional server, tolerant cardinality (Off-mode). buzz-media/src/error.rs: - Add DuplicateTag(&'static str) variant. - Split IntoResponse: missing Authorization -> 401 (missing_evidence); wrong scheme, malformed, duplicate tags -> 403 (evidence_rejected). buzz-relay/src/api/media.rs: - Add blossom_strictness_from_state() helper (TODO: wire to config.nip_fi.mode when #7264 lands; defaults to Permissive on main). - extract_blossom_auth: detect and reject repeated Authorization header values -> DuplicateTag("Authorization") -> 403. - Both call sites (upload + read) now pass strictness to verifier. desktop/src-tauri/src/commands/media.rs: - sign_blossom_upload_auth: server tag now mandatory (errors if relay URL yields no authority); was conditional. - Upload token expiry: 60s unconditionally (was 3600s video / 300s image). - MEDIA_GET_AUTH_EXPIRY_SECS: 60s (was 600s). desktop/src-tauri/src/media_proxy.rs: - proxy_handler + handle_buzz_media: single re-mint+retry on 401 or 403 for range requests (expired 60s token mid-stream). docs/nips/NIP-FI.md: - Remove stale compliance note; replace with resolved statement. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… on #7264 The strict verifier exists but runs in Permissive mode until the NIP-FI HTTP enforcement PR (#7264) merges and the stub in blossom_strictness_from_state is replaced with the live mode derivation. The deny-map gap (S4) is still a named known gap. Remove the premature 'now compliant' claim and state exactly what is true: verifier hardening implemented, engagement conditional on #7264 landing, deny-map pending S4. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ll minters Fix two IMPORTANT blockers from Thufir pass 1: **Fix 1 — mode-aware denial response shape (buzz-media, buzz-relay)** All strict-verifier failures previously collapsed to a generic JSON 401. NIP-FI §755-773 requires: - Missing Authorization → 401 + WWW-Authenticate: Nostr + text/plain body 'authentication required\n' - Malformed/invalid/expired proof → 403 + text/plain 'evidence rejected\n' The shape is Strict-only — Permissive (Off-mode) keeps the legacy JSON 401 unchanged [FI-INV-15]. Implementation: - buzz-media/error.rs: add BlossomDenialKind enum (MissingEvidence / EvidenceRejected) and blossom_denial_kind() method on MediaError - buzz-media/lib.rs: export BlossomDenialKind - buzz-relay/api/media.rs: add MediaDenial(MediaError, BlossomStrictness) newtype implementing IntoResponse with mode-aware shaping via DenialClass byte contract from buzz-auth. Wire through AuthenticatedUpload extractor (Rejection = MediaDenial), authenticate_media_read, get_blob, head_blob. Non-auth errors fall through to MediaError::into_response() via From impl. Tests: response-shape tests for Strict missing-evidence (401 + WWW-Auth + text body), Strict evidence-rejected (403 + text/plain 'evidence rejected\n', no WWW-Authenticate), Permissive regression pins for both classes (JSON 401, no WWW-Authenticate). Classification tests for all 15 error variants. **Fix 2 — 60s proof window across all first-party minters** All minters updated to expiration <= created_at + 60s and mandatory upload server tag, mirroring the desktop pattern established in the prior commit: - buzz-cli/src/client.rs: read +60 (was +600), upload +60/server mandatory (was +600/+3600 conditional server, mime-gated expiry removed) - buzz-dev-mcp/src/view_image.rs: MEDIA_GET_AUTH_EXPIRY_SECS 60 (was 600), comment updated; existing parametric test covers the updated constant - mobile/lib/shared/relay/media_auth.dart: _mediaGetAuthLifetimeSeconds 60 (was 600); margin=lifetime → mint-per-request pattern, comment updated - mobile/lib/shared/relay/media_upload.dart: _uploadAuthLifetimeSeconds 60 (was 300); server tag mandatory (was conditional on extractServerAuthority) - scripts/test-video-upload.sh: expiry +60 (was +300), adds server tag - buzz-relay/src/api/media.rs test fixtures: +55 (was +300) in media_get_tags_for and media_read_rejects_upload_verb_wrong_server_and_wrong_x - buzz-test-client e2e fixtures: +55 + mandatory server tag in all three e2e_media* test files (relay_server_authority() helper added) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The two loop tests moved MediaError into MediaDenial but then referenced the original variable in format strings. Capture the debug repr as 'label' before the move. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
get_blob and head_blob are pub fn returning Result<_, MediaDenial> which exposed the private type in the public interface (E0446). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…r boundaries
Three call sites in the upload path bypassed the MediaDenial strictness
split via From<MediaError> for MediaDenial, which hardcodes Permissive:
1. ok_or(MissingTag("x-sha-256")) — replaced with ok_or_else using
media_denial(e, strictness).
2. HashMismatch.into() × 2 (malformed + unmatched x tag) — replaced with
media_denial(HashMismatch, strictness).
3. upload_blob returned Result<_, MediaError>, so post-body failures hit
MediaError::into_response() directly.
Fix: add strictness: BlossomStrictness to AuthenticatedUpload, derived in
the extractor; change upload_blob to return Result<_, MediaDenial>; apply
media_denial through both the outer (protect-layer) and inner (async body)
error stacks. Non-Blossom errors (I/O, fencing, concurrency) fall through
to the legacy shape in both modes as the wrapper already guarantees.
Add 4 response-shape tests (Strict 403 + Permissive 401 for each of
MissingTag("x-sha-256") and HashMismatch), pinning status, content-type,
body bytes, and WWW-Authenticate absence.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…; fix clippy Mobile tests and class documentation still specified the superseded 600/300-second proof lifetime after the NIP-FI 60-second fix landed. media_auth.dart:24-28: rewrite class doc — with lifetime == margin == 60s, _refreshAt == signedAt and the cache never hits; describe the intentional mint-per-request pattern instead of the stale memoization claim. media_image_test.dart:34-58 (memoization group): rewrite to pin mint-per-request behavior. 'repeated calls return byte-identical headers' (asserting identical()) and 're-signs only at +540s boundary' both contradicted production; replaced with tests that assert consecutive calls produce distinct headers/Authorization values including without advancing the clock. media_upload_test.dart:295: expiration literal 1700000600 (+600s) → 1700000060 (+60s). media_upload_test.dart:422: expiration literal 1700000300 (+300s) → 1700000060 (+60s). All 48 affected Dart tests pass on the pinned Flutter 3.41.7 toolchain (confirmed the pre-fix image_test memoization tests were failing — asserting identical() true when mint-per-request returns distinct instances every call). auth.rs:321: x_tags.iter().any(|&v| v == sha256) → x_tags.contains(&sha256) (clippy::manual_contains — fixes Rust Lint + Windows Rust CI red lanes at 5df4caf). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…r-move in error.rs test
upload_blob, get_blob, and head_blob were pub but only reachable from
router.rs within the same crate — private_interfaces lint fired because
their return types include pub(crate) MediaDenial. Change all three to
pub(crate) to match the crate's visibility posture.
Also fix a borrow-after-move in buzz-media error.rs:
evidence_rejected_errors_permissive_shape_is_json_401 used {error:?}
after error.into_response() moved it. Add let label = format!() before
the move (same pattern already applied in media.rs tests).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…403) The NIP-FI rejection table (§Public denial classes and transport codes) maps `evidence_rejected` — malformed, invalid, OR expired evidence — to HTTP 403. The previous code mapped signature failures, expired tokens, missing tags, hash/server mismatches, etc. to 401, matching a now-removed oracle-enumeration rationale that the spec does not make. Changes: - Merge the two `IntoResponse` denial branches into one 403 arm covering all structurally present but invalid/malformed/expired proofs. Only absent-header (`MissingAuth`) remains 401. - Update the body string from "authentication failed" / "authorization denied" to the spec's fixed string "evidence rejected" / "authentication required" for the respective classes. - Fix the misleading IntoResponse comment and the now-stale test section header. - Update the `evidence_rejected_errors_permissive_shape_is_json_401` unit test → `evidence_rejected_errors_return_json_403`; add `InvalidAuthKind`, `InvalidAuthVerb`, `InvalidAuthEvent` to coverage. - Fix all five `test_auth_*` assertions in e2e_media_extended.rs: wrong kind, missing t tag, missing expiration, expired token, empty content all expect 403 (not 401). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ilures The two CI-failing e2e tests (test_auth_wrong_kind, test_auth_empty_content) asserted 401 but the server correctly returns 403: InvalidAuthKind and InvalidAuthEvent are structural format errors that into_response() maps to 403 (observable to any pre-NIP-FI Blossom client — not oracle information). The previous fix attempt incorrectly merged all evidence_rejected variants to 403 in into_response(), which broke the Permissive-mode invariant: MediaDenial (buzz-relay) is the correct layer for the full NIP-FI rejection table; into_response() is the legacy/Permissive fallback path [FI-INV-15]. Signature, expiry, missing-tag, hash/server-mismatch failures return 401 in Permissive mode to prevent oracle enumeration — MediaDenial overrides them to 403 in Strict mode. The media.rs Permissive pin tests document this split. Changes: - error.rs: restore two-branch IntoResponse (structural format → 403, oracle- guard auth failures → 401). Rewrite comment to document the layering. Replace the now-correct-name unit tests: structural_format_errors_return_ json_403 + evidence_rejected_errors_return_json_401_in_permissive_mode. - e2e_media_extended.rs: fix only wrong_kind (401→403) and empty_content (401→403); revert missing_t_tag, missing_expiration, expired_token to 401 — they exercise the Permissive path and correctly return 401. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
MediaError::into_response() is the legacy/Permissive compatibility path. FI-INV-15 requires it to preserve pre-NIP-FI behavior: all auth failures return a single JSON 401 "authentication failed" response regardless of failure class. The 77fabf3 and 151ec74 commits both violated this invariant by splitting auth failures into 401 and 403 arms inside into_response(). The NIP-FI rejection-table split (missing_evidence → 401, evidence_rejected → 403) belongs exclusively to MediaDenial in buzz-relay, which already implements it correctly under BlossomStrictness::Strict. Routing is currently hardcoded Permissive, so all live traffic and the Relay E2E lane exercise this legacy path. Changes: - error.rs: collapse the two auth arms back to a single 401 arm covering all variants (MissingAuth, InvalidAuthScheme, InvalidBase64, InvalidAuthEvent, InvalidAuthKind, InvalidAuthVerb, DuplicateTag, InvalidSignature, TokenExpired, TimestampOutOfWindow, Unauthorized, TokenRevoked, PubkeyMismatch, HashMismatch, ServerMismatch, MissingTag). Replace the split unit tests with a single exhaustive all_auth_failures_return_json_401_in_permissive_path pin. - e2e_media_extended.rs: revert all 5 test_auth_* assertions to 401; the suite exercises the Permissive path via hardcoded Permissive routing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add a body assertion to all_auth_failures_return_json_401_in_permissive_path
so the pin fully captures the FI-INV-15 byte-identical claim: all 16 auth
variants must emit {"error":"authentication failed"} as the JSON body.
The test is promoted to async (#[tokio::test]) to collect the response body
via axum::body::to_bytes; tokio with test-util is already a dev dep.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
🔐 Codex Security Review
|
…-body only Two security findings from Codex review of #7288: Finding 3 (security): In verify_blossom_auth_event_for_verb, the t tag arm incremented t_count unconditionally before checking content, so a t tag with no value (e.g. ["t"]) satisfied the required-tag check (t_count > 0) without binding any verb. A verb-less proof was accepted for both upload and get in both Strict and Permissive modes. Fix: a t tag only counts toward t_count when it has non-empty content equal to the requested verb. A valueless or empty-string t tag is ignored for cardinality purposes (matches origin/main's found_t semantics). Duplicate-t cardinality in Strict mode is checked after confirming the tag is valid, not before. Also audited expiration/x/server tag handling: these are all gated on tag.content() already, so valueless variants of those tags are already safe. Finding 2 (correctness): Both buffered and video upload paths called verify_blossom_upload_auth post-body, which re-runs the full verifier including expiry/freshness checks. With 60s minted tokens (correct per NIP-FI Freshness), any upload taking >60s fails AFTER transferring the full body. Fix: introduce verify_upload_hash_only — checks only the x-tag hash against the computed SHA-256. Replace both post-body verify_blossom_upload_auth calls with this targeted check. The pre-body gate at the relay handler already enforces signature, kind, freshness, cardinality, and server; the only thing unknown before body transfer is the content hash. Tests added: valueless t (Strict+Permissive), empty-string t (Strict), valueless x on upload. All 147 buzz-media lib tests pass; all 44 api::media + 56 api::admin buzz-relay tests pass. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
In Strict mode, count every tag whose field name is 't' before
validating its content. The previous implementation counted only
valid-valued matching tags, so a proof with both ["t"] (valueless) and
["t","upload"] (valid) silently ignored the malformed instance and
admitted a two-tag proof as exactly one.
NIP-FI.md:658-666 requires Strict to reject malformed, empty, duplicate,
or conflicting instances as evidence_rejected. The fix counts first,
gates on cardinality (>1 → DuplicateTag("t")), then validates content
(empty/valueless/wrong-verb → InvalidAuthVerb). Permissive keeps the
origin/main found_t semantics unchanged.
Updated tests: the two malformed-alone Strict tests now expect
InvalidAuthVerb (counted as one, content check fires) instead of the
stale MissingTag. Added four new regressions: valueless+valid combo,
empty-string+valid combo (both reject in Strict), and valueless+valid x
combo (confirms x_count increments unconditionally → DuplicateTag("x")).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The two malformed-t-alone Strict tests were named 'is_not_counted_strict' after the previous semantics (ignore → MissingTag). At 41ee983 the behavior became count-then-reject → InvalidAuthVerb, so the names were misleading. Rename to test_strict_rejects_valueless_t_tag and test_strict_rejects_empty_string_t_tag. Logic unchanged. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
Reviewed head 654071e96fc65efb362498adad077e02083126c0 against base 752cbfc0375efc8bac3d5cc2a5716250bc0be234. Five P2 findings: three compatibility/playback regressions and two incomplete Strict-mode contracts. Source-only review: immutable source and locked dependency implementations were inspected and hash-verified; no checkout, builds, tests, or PR/dependency-code execution. The reproduction recipes below are not runtime-verified.
1. [P2] Preserve any-matching-server semantics in Permissive mode
At auth.rs:245–249, the verifier checks only the first stored server value. A fresh, correctly signed upload proof with valid content, expiration and matching x, containing [["server","other.example"],["server","relay.example"]], is rejected on relay.example. The base verifier accepts any matching server. This affects the shared read/upload verifier even with NIP-FI Off, which is the head relay’s current mode. It breaks previously accepted multi-server proofs, contrary to the stated legacy-compatibility contract.
Restore any-match semantics in Permissive only. Cover both server orders there while preserving Strict duplicate rejection.
2. [P2] Refresh Android video proofs at the network-request boundary
media_auth.dart:10–16 reduces the read proof from 600 to 60 seconds, but the Android video path signs once into VideoPlayerController.networkUrl. The locked video_player_android 2.9.5 transport copies those headers into static DefaultHttpDataSource request properties. Calling headersFor afresh elsewhere does not refresh this controller.
Recipe: open a large video on Android against an auth-enforcing relay, pause 90 seconds, then seek outside buffered data so another request is required. That request retains the expired proof and reaches expiry rejection. The viewer’s download fallback covers initialization failures only; its effect reruns only when videoUrl changes. The newly broken window is 60–600 seconds. This does not claim that buffered playback or an already admitted response stops at expiry.
Use the existing authenticated local-file playback path on Android too, or provide genuine per-request signing. Do not lengthen Strict proofs to hide the lifecycle mismatch.
3. [P2] Establish relay compatibility before shipping 60-second upload proofs
commands/media.rs:418–427 unconditionally shortens Desktop upload proofs to 60 seconds; CLI and mobile do the same. However, the base relay re-runs full verification after streamed video completion, including expiration. Buffered uploads also reverify after the body.
Recipe: new client → base relay, valid size-compliant video with prompt headers and a body transfer lasting 90 seconds. It now fails after the full transfer, whereas the old Desktop/CLI video proofs allowed 3600 seconds and mobile allowed 300. The CLI video timeout is 600 seconds, so this is within its supported transfer window. Head/head correctly avoids this by doing hash-only completion; that does not repair new-client/old-relay deployments. Desktop, Relay and Mobile explicitly release independently, and the reviewed sequencing note requires #7264 before activation, not deployment of this admission change before client distribution.
Enforce an explicit relay-first compatibility floor before shipping these clients, or negotiate compatible legacy/Strict behavior. Re-signing a retry cannot fix a transfer that itself exceeds 60 seconds; blindly extending Strict TTL is not a solution.
4. [P2] Reject a present but valueless x on Strict reads
auth.rs:364–369 drops x tags without content, then the Strict branch treats an empty result as host-wide authorization. The locked nostr 0.44.7 parser preserves ["x"]; Tag::content() returns None rather than rejecting the event.
Recipe: call the Strict read verifier with a correctly signed fresh t=get proof, nonempty content, valid expiration, matching server and exactly one ["x"]. It passes common cardinality checks and returns success for an arbitrary requested parent hash. The read contract requires a present x to match; malformed fields must not become absent scope. Preserve tag presence and reject a valueless x, with a single-valueless-x GET regression case.
This is incomplete new Strict hardening, not a newly reachable production bypass: the head relay intentionally still selects Permissive. No cross-tenant access is claimed.
5. [P2] Carry Strict mode through membership denials and classify local-policy failures
The new MediaDenial conversion defaults to Permissive. Both real membership-denial sites (upload, read) use that implicit conversion. Even explicitly wrapping RelayMembershipRequired as Strict cannot fix the response: the new classifier omits it, so it falls through to legacy 403 JSON with relay membership required.
The local-policy denial contract requires fixed authorization denied\n with text/plain; charset=utf-8. Preserve the active mode at both policy-denial sites and map membership rejection to AuthorizationDenied. Cover actual upload/read membership rejection, not only a directly constructed missing-auth wrapper.
This is a dormant Strict activation blocker, not a new Off-mode response leak. It concerns the existing membership gate, not deferred assertion pairing or issuer deny-map work; merely replacing the activation stub leaves both defects intact.
Scope and exit criteria
Resolve the three current compatibility/playback regressions and the two Strict implementation gaps above. I am not asking to reintroduce post-body expiry checks, relax the 60-second Strict contract, or complete the explicitly deferred #7264 pairing/deny-map work here. Desktop proxies already mint per upstream request; their bounded range retry is not a playback-token-cache defect. The proposed stale-URL/different-key scenario lacked a supported production witness and is excluded.
Brings
buzz-media,buzz-relay, all first-party kind-24242 minters, and desktop token minting into compliance with NIP-FI §kind-24242. Resolves the compliance note added in #7278.What changed
buzz-media/src/auth.rsBlossomStrictnessenum (Strict|Permissive).Strictapplies full NIP-FI rules (active modes);Permissivepreserves pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15].verify_blossom_auth_event_for_verbwith count-based cardinality: exactly onet/expiration/serverinStrict; duplicatexalso rejected.Permissiveretains tolerant boolean semantics.Strict: mandatoryservertag on all proofs (upload + read); absent or mismatched →evidence_rejected(ServerMismatch).Strict: 60-second proof window (now - created_at <= 60s,expiration <= created_at + 60s).Permissive: 3600s / optional server.buzz-media/src/error.rsDuplicateTag(&'static str)variant.blossom_denial_kind()method: classifies each auth error asMissingEvidence(absentAuthorization) orEvidenceRejected(structurally present but invalid/expired/mismatched). Non-auth errors returnNone.IntoResponseretains the legacy JSON 401 shape (Permissive / Off-mode regression invariant [FI-INV-15]).buzz-relay/src/api/media.rsMediaDenial(MediaError, BlossomStrictness)wrapper. InStrictmode, maps throughDenialClassto byte-exact NIP-FI fixed responses (401authentication required\n+WWW-Authenticate: Nostrfor missing auth; 403evidence rejected\nfor rejected evidence). InPermissivemode, falls through to legacy JSON 401 unchanged.blossom_strictness_from_state()stub defaults toPermissiveon currentmain; one-line swap tostate.config.nip_fi.is_enforce()after feat(relay): enforce NIP-FI assertion+NIP-98 pairing on HTTP ingress #7264 merges.extract_blossom_auth: detect and reject repeatedAuthorizationheader values →DuplicateTag("Authorization")→ 403.strictnessto the verifier.WWW-Authenticate).desktop/src-tauri/src/commands/media.rssign_blossom_upload_auth:servertag is now mandatory.MEDIA_GET_AUTH_EXPIRY_SECS: 60s (was 600s).desktop/src-tauri/src/media_proxy.rscrates/buzz-cli/src/client.rsservertag mandatory on upload.crates/buzz-dev-mcp/src/view_image.rsMEDIA_GET_AUTH_EXPIRY_SECS: 60s (was 600s). Test updated to assert ≤60s.mobile/lib/shared/relay/media_auth.dart_mediaGetAuthLifetimeSeconds: 60 (was 600). Refresh margin updated to match.mobile/lib/shared/relay/media_upload.dart_uploadAuthLifetimeSeconds: 60 (was 300).servertag is now mandatory.scripts/test-video-upload.sh+60expiry and mandatoryservertag.buzz-test-client/tests/e2e_media*.rs+buzz-relay/src/api/media.rstest fixturesservertag (5s slack for execution time).docs/nips/NIP-FI.mdSequencing note
blossom_strictness_from_statedefaults toPermissiveuntil #7264 merges andconfig.nip_fiis wired intoAppState. The wiring commit (replacing the stub withstate.config.nip_fi.is_enforce()and adding live both-directions regression tests) lands in this PR after #7264 merges.